From a8d5c39ff6f94716575e552fd88ebaca74413e48 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 15:23:41 +0000 Subject: [PATCH 01/84] =?UTF-8?q?=F0=9F=A4=96=20feat:=20build=20native=20X?= =?UTF-8?q?um=20mobile=20workspace=20and=20chat=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement native connection, grouped workspace navigation, conversation streaming and actions, server-backed creation, model settings, read-only changes, and connection settings. The mobile client uses shared API/types and aborts workspace-bound work on navigation. Validation: 15 TS/TSX syntax transforms, formatting, seven settings behavior checks. Full mobile typecheck and visual dogfood depend on the parent scaffold and transport integration. --- packages/mobile/App.tsx | 170 +++++++++ packages/mobile/src/components/Controls.tsx | 209 +++++++++++ packages/mobile/src/components/Markdown.tsx | 73 ++++ packages/mobile/src/components/Message.tsx | 209 +++++++++++ packages/mobile/src/screens/ChangesScreen.tsx | 109 ++++++ packages/mobile/src/screens/ConnectScreen.tsx | 159 ++++++++ .../mobile/src/screens/ConversationScreen.tsx | 345 ++++++++++++++++++ .../mobile/src/screens/CreateWorkspace.tsx | 204 +++++++++++ packages/mobile/src/screens/ModelSettings.tsx | 128 +++++++ packages/mobile/src/screens/Navigator.tsx | 179 +++++++++ .../mobile/src/screens/SettingsScreen.tsx | 57 +++ packages/mobile/src/settings.ts | 57 +++ packages/mobile/src/theme.ts | 30 ++ packages/mobile/src/useConversation.ts | 48 +++ packages/mobile/src/useProjects.ts | 50 +++ 15 files changed, 2027 insertions(+) create mode 100644 packages/mobile/App.tsx create mode 100644 packages/mobile/src/components/Controls.tsx create mode 100644 packages/mobile/src/components/Markdown.tsx create mode 100644 packages/mobile/src/components/Message.tsx create mode 100644 packages/mobile/src/screens/ChangesScreen.tsx create mode 100644 packages/mobile/src/screens/ConnectScreen.tsx create mode 100644 packages/mobile/src/screens/ConversationScreen.tsx create mode 100644 packages/mobile/src/screens/CreateWorkspace.tsx create mode 100644 packages/mobile/src/screens/ModelSettings.tsx create mode 100644 packages/mobile/src/screens/Navigator.tsx create mode 100644 packages/mobile/src/screens/SettingsScreen.tsx create mode 100644 packages/mobile/src/settings.ts create mode 100644 packages/mobile/src/theme.ts create mode 100644 packages/mobile/src/useConversation.ts create mode 100644 packages/mobile/src/useProjects.ts diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx new file mode 100644 index 00000000000..90fc876d75c --- /dev/null +++ b/packages/mobile/App.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from "react"; +import { Modal, StatusBar, Text, useWindowDimensions, View } from "react-native"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +import { Menu, Plus } from "lucide-react-native"; +import { clearCredentials } from "./src/credentials"; +import { ConnectScreen } from "./src/screens/ConnectScreen"; +import type { Connection } from "./src/screens/ConnectScreen"; +import { Navigator } from "./src/screens/Navigator"; +import { ConversationScreen } from "./src/screens/ConversationScreen"; +import { CreateWorkspace } from "./src/screens/CreateWorkspace"; +import { ChangesScreen } from "./src/screens/ChangesScreen"; +import { SettingsScreen } from "./src/screens/SettingsScreen"; +import { Button, Header, IconButton, Loading, Notice } from "./src/components/Controls"; +import { useProjects } from "./src/useProjects"; +import { colors, layout } from "./src/theme"; + +export default function App() { + const [connection, setConnection] = useState(null); + useEffect(() => () => connection?.close(), [connection]); + return ( + + + + {connection ? ( + setConnection(null)} /> + ) : ( + + )} + + + ); +} + +function ConnectedApp(props: { connection: Connection; onDisconnect: () => void }) { + const { width } = useWindowDimensions(); + const wide = width >= 900; + const data = useProjects(props.connection.client); + const [selectedId, setSelectedId] = useState(null); + const [drawer, setDrawer] = useState(false); + const [create, setCreate] = useState(false); + const [screen, setScreen] = useState<"chat" | "changes" | "settings">("chat"); + const [disconnectError, setDisconnectError] = useState(null); + const [disconnecting, setDisconnecting] = useState(false); + const selected = data.workspaces.find((workspace) => workspace.id === selectedId); + async function disconnect() { + setDisconnecting(true); + setDisconnectError(null); + try { + await clearCredentials(); + props.onDisconnect(); + } catch { + setDisconnectError( + "Could not clear secure credentials. Try again before leaving this device." + ); + setDisconnecting(false); + } + } + const navigation = ( + { + setSelectedId(workspace.id); + setDrawer(false); + setScreen("chat"); + }} + onCreate={() => { + setDrawer(false); + setCreate(true); + }} + onSettings={() => { + setDrawer(false); + setScreen("settings"); + }} + onClose={wide ? undefined : () => setDrawer(false)} + /> + ); + return ( + + {wide && {navigation}} + + + {selected ? ( + setDrawer(true)} + onChanges={() => setScreen("changes")} + /> + ) : ( + <> +
setDrawer(true)} + /> + ) + } + /> + + {data.loading ? ( + + ) : data.error ? ( + {data.error} + ) : ( + <> + Make space for your next idea. + + Select a workspace or start a new conversation. Everything stays on your Xum + server. + + + + )} + + + )} + + {screen === "changes" && selected && ( + setScreen("chat")} + /> + )} + {screen === "settings" && ( + setScreen("chat")} + error={disconnectError} + busy={disconnecting} + /> + )} + + {!wide && drawer && ( + setDrawer(false)}> + {navigation} + + )} + {create && ( + setCreate(false)} + onCreated={(workspace) => { + setSelectedId(workspace.id); + setScreen("chat"); + setCreate(false); + data.retry(); + }} + /> + )} + + ); +} diff --git a/packages/mobile/src/components/Controls.tsx b/packages/mobile/src/components/Controls.tsx new file mode 100644 index 00000000000..a72a39cc2c9 --- /dev/null +++ b/packages/mobile/src/components/Controls.tsx @@ -0,0 +1,209 @@ +import type { ReactNode } from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import type { TextInputProps } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { AlertCircle, ArrowLeft, X } from "lucide-react-native"; +import type { LucideIcon } from "lucide-react-native"; +import { colors, layout } from "../theme"; + +export function IconButton(props: { + label: string; + icon: LucideIcon; + onPress: () => void; + disabled?: boolean; + color?: string; +}) { + const Icon = props.icon; + return ( + [ + styles.iconButton, + { opacity: props.disabled ? 0.35 : pressed ? 0.6 : 1 }, + ]} + > + + + ); +} + +export function Button(props: { + children: string; + onPress: () => void; + disabled?: boolean; + busy?: boolean; + secondary?: boolean; + icon?: LucideIcon; +}) { + const Icon = props.icon; + return ( + [ + styles.button, + props.secondary && styles.secondary, + { opacity: props.disabled || props.busy ? 0.5 : pressed ? 0.7 : 1 }, + ]} + > + {props.busy ? ( + + ) : Icon ? ( + + ) : null} + {props.children} + + ); +} + +export function Field(props: TextInputProps & { label: string }) { + const { label, ...inputProps } = props; + return ( + + {label} + + + ); +} + +export function Notice(props: { children: string; onRetry?: () => void }) { + return ( + + + + + {props.children} + + + {props.onRetry && ( + + )} + + ); +} + +export function Loading(props: { label?: string }) { + return ( + + + {props.label ?? "Loading…"} + + ); +} + +export function Header(props: { + title: string; + subtitle?: string; + onBack?: () => void; + trailing?: ReactNode; +}) { + return ( + + {props.onBack && } + + + {props.title} + + {props.subtitle && ( + + {props.subtitle} + + )} + + {props.trailing} + + ); +} + +export function Sheet(props: { title: string; children: ReactNode; onClose: () => void }) { + return ( + + +
} + /> + + {props.children} + + + + ); +} + +const styles = StyleSheet.create({ + iconButton: { + minWidth: 44, + minHeight: 44, + alignItems: "center", + justifyContent: "center", + borderRadius: 8, + }, + button: { + minHeight: 48, + borderRadius: 10, + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + backgroundColor: colors.accent, + }, + secondary: { backgroundColor: colors.elevated }, + buttonText: { color: colors.bright, fontSize: 15, fontWeight: "600" }, + input: { + color: colors.bright, + backgroundColor: colors.panel, + borderColor: colors.border, + borderWidth: 1, + borderRadius: 10, + minHeight: 48, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + }, + notice: { + padding: 14, + gap: 12, + backgroundColor: colors.panel, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + }, + loading: { padding: 28, gap: 12, alignItems: "center", justifyContent: "center" }, + header: { + flexDirection: "row", + gap: 8, + alignItems: "center", + paddingHorizontal: 12, + minHeight: 68, + borderBottomColor: colors.border, + borderBottomWidth: 1, + }, + headerTitle: { color: colors.bright, fontWeight: "600", fontSize: 16 }, +}); diff --git a/packages/mobile/src/components/Markdown.tsx b/packages/mobile/src/components/Markdown.tsx new file mode 100644 index 00000000000..1b3c6ed4637 --- /dev/null +++ b/packages/mobile/src/components/Markdown.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from "react"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { colors, layout, mono } from "../theme"; + +function inline(text: string): ReactNode[] { + return text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g).map((part, index) => { + if (part.startsWith("`") && part.endsWith("`")) + return ( + + {part.slice(1, -1)} + + ); + if (part.startsWith("**") && part.endsWith("**")) + return ( + + {part.slice(2, -2)} + + ); + return part; + }); +} + +// Render untrusted model/repository text only through native Text, never HTML. +export function Markdown(props: { text: string }) { + const blocks = props.text.split(/```([^\n]*)\n([\s\S]*?)(?:```|$)/g); + return ( + + {blocks.map((block, index) => { + if (index % 3 === 1) return null; + if (index % 3 === 2) + return ( + + {blocks[index - 1] || "CODE"} + + + {block.trimEnd()} + + + + ); + return block + .split(/\n\s*\n/) + .filter(Boolean) + .map((paragraph, line) => { + const heading = /^(#{1,6})\s+(.+)$/.exec(paragraph); + return ( + + {inline(heading ? heading[2] : paragraph.replace(/^[-*] /gm, "• "))} + + ); + }); + })} + + ); +} + +const styles = StyleSheet.create({ + inlineCode: { fontFamily: mono, backgroundColor: colors.elevated, color: colors.bright }, + code: { + backgroundColor: colors.panel, + borderRadius: 8, + padding: 12, + gap: 10, + borderWidth: 1, + borderColor: colors.border, + }, + codeText: { fontFamily: mono, fontSize: 12, lineHeight: 20, color: colors.text }, + heading: { fontSize: 19, lineHeight: 27, fontWeight: "600", color: colors.bright }, +}); diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx new file mode 100644 index 00000000000..261451a6072 --- /dev/null +++ b/packages/mobile/src/components/Message.tsx @@ -0,0 +1,209 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Brain, ChevronDown, ChevronRight, File, Wrench } from "lucide-react-native"; +import type { MuxMessage, MuxToolPart } from "../../../../src/common/types/message"; +import { Button, Field, Notice } from "./Controls"; +import { Markdown } from "./Markdown"; +import { colors, layout, mono } from "../theme"; + +export function Message(props: { + message: MuxMessage; + canAnswer: boolean; + onAnswer: (toolCallId: string, answers: Record) => Promise; +}) { + const user = props.message.role === "user"; + return ( + + + {user ? "YOU" : props.message.role === "assistant" ? "XUM" : "SYSTEM"} + + {props.message.parts.map((part, index) => { + switch (part.type) { + case "text": + return ; + case "reasoning": + return ( + + + + ); + case "dynamic-tool": + return ( + + ); + case "file": + return ( + + + {part.filename ?? part.mediaType} · attachment + + ); + } + })} + + ); +} + +function Disclosure(props: { label: string; reasoning?: boolean; children: ReactNode }) { + const [expanded, setExpanded] = useState(false); + const Icon = props.reasoning ? Brain : Wrench; + return ( + + setExpanded(!expanded)} + style={styles.disclosureHeader} + > + + + {props.label} + + {expanded ? ( + + ) : ( + + )} + + {expanded && {props.children}} + + ); +} + +function printable(value: unknown): string { + return ( + typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "No output") + ).slice(0, 24000); +} + +function Tool(props: { + part: MuxToolPart; + canAnswer: boolean; + onAnswer: (toolCallId: string, answers: Record) => Promise; +}) { + const questions = + props.part.toolName === "ask_user_question" && props.part.state === "input-available" + ? questionTexts(props.part.input) + : []; + return ( + + + INPUT + + {printable(props.part.input)} + + {props.part.state === "output-available" && ( + <> + OUTPUT + + {printable(props.part.output)} + + + )} + + Large tool details are limited to 24,000 characters on mobile. + + + {questions.length > 0 && ( + props.onAnswer(props.part.toolCallId, answers)} + /> + )} + + ); +} + +function questionTexts(input: unknown): string[] { + if ( + !input || + typeof input !== "object" || + !("questions" in input) || + !Array.isArray(input.questions) + ) + return []; + return input.questions.flatMap((question: unknown) => + question && + typeof question === "object" && + "question" in question && + typeof question.question === "string" + ? [question.question] + : [] + ); +} + +function QuestionForm(props: { + questions: string[]; + disabled: boolean; + onSubmit: (answers: Record) => Promise; +}) { + const [answers, setAnswers] = useState>({}); + const [busy, setBusy] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + async function submit() { + if (busy || submitted) return; + setBusy(true); + setError(null); + try { + await props.onSubmit(answers); + setSubmitted(true); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not send answers."); + } finally { + setBusy(false); + } + } + return ( + + YOUR INPUT IS NEEDED + {props.questions.map((question) => ( + setAnswers({ ...answers, [question]: answer })} + placeholder="Your answer…" + multiline + editable={!props.disabled && !busy && !submitted} + /> + ))} + {error && {error}} + + + ); +} + +const styles = StyleSheet.create({ + message: { gap: 12, paddingVertical: 20, paddingHorizontal: 4 }, + user: { backgroundColor: colors.user, borderRadius: 12, padding: 16, marginVertical: 8 }, + disclosure: { borderWidth: 1, borderColor: colors.border, borderRadius: 9, overflow: "hidden" }, + disclosureHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + minHeight: 44, + paddingHorizontal: 12, + }, + output: { color: colors.text, fontFamily: mono, fontSize: 12, lineHeight: 19 }, + question: { gap: 14, borderRadius: 10, backgroundColor: colors.panel, padding: 16 }, +}); diff --git a/packages/mobile/src/screens/ChangesScreen.tsx b/packages/mobile/src/screens/ChangesScreen.tsx new file mode 100644 index 00000000000..0a035bd92fa --- /dev/null +++ b/packages/mobile/src/screens/ChangesScreen.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { RefreshCw } from "lucide-react-native"; +import type { MobileClient } from "../api"; +import { Header, IconButton, Loading, Notice } from "../components/Controls"; +import { colors, layout, mono } from "../theme"; + +export function ChangesScreen(props: { + client: MobileClient; + workspaceId: string; + onBack: () => void; +}) { + const [output, setOutput] = useState(null); + const [note, setNote] = useState(null); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + function retry() { + setGeneration((value) => value + 1); + } + useEffect(() => { + const controller = new AbortController(); + setOutput(null); + setError(null); + setNote(null); + // Fixed argv prevents branch/file names from becoming shell code. Disable external + // diff/textconv hooks: this view only reads tracked worktree changes against HEAD. + props.client.workspace + .executeBash( + { + workspaceId: props.workspaceId, + script: "", + command: "git", + args: [ + "--no-pager", + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "HEAD", + "--", + ], + options: { timeout_secs: 20, cwdMode: "repo-root" }, + }, + { signal: controller.signal } + ) + .then((result) => { + if (controller.signal.aborted) return; + if (!result.success) throw new Error(result.error); + if (!result.data.success) throw new Error(result.data.error); + setOutput(result.data.output); + setNote( + result.data.truncated + ? "The server truncated this diff. Review the full changes on desktop before making decisions." + : (result.data.note ?? null) + ); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) + setError(cause instanceof Error ? cause.message : "Could not load changes."); + }); + return () => controller.abort(); + }, [props.client, props.workspaceId, generation]); + return ( + +
} + /> + + + Read-only. Includes staged and unstaged tracked files; untracked files and changes already + committed are not included. + + {error && {error}} + {output === null && !error && } + {note && {note}} + {output === "" && No tracked changes against HEAD.} + {output && ( + + + {output.split("\n").map((line, index) => ( + + {line || " "} + + ))} + + + )} + + + ); +} diff --git a/packages/mobile/src/screens/ConnectScreen.tsx b/packages/mobile/src/screens/ConnectScreen.tsx new file mode 100644 index 00000000000..74857993ac0 --- /dev/null +++ b/packages/mobile/src/screens/ConnectScreen.tsx @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from "react"; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from "react-native"; +import { ArrowRight, ShieldCheck } from "lucide-react-native"; +import { connect } from "../connection"; +import { loadCredentials, saveCredentials } from "../credentials"; +import { Button, Field, Loading, Notice } from "../components/Controls"; +import { colors, layout } from "../theme"; + +export type Connection = Awaited>; + +export function ConnectScreen(props: { onConnect: (connection: Connection) => void }) { + const [endpoint, setEndpoint] = useState(""); + const [token, setToken] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const request = useRef(null); + + useEffect(() => { + let active = true; + loadCredentials() + .then((saved) => { + if (!active) return; + if (saved) { + setEndpoint(saved.endpoint); + setToken(saved.token); + } + }) + .catch(() => { + if (active) + setError("Saved connection could not be read. Enter your server details again."); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + request.current?.abort(); + }; + }, []); + + async function submit() { + if (request.current) return; + const controller = new AbortController(); + request.current = controller; + setBusy(true); + setError(null); + let connection: Connection | undefined; + try { + connection = await connect(endpoint.trim(), token.trim(), { signal: controller.signal }); + if (controller.signal.aborted) { + connection.close(); + return; + } + await saveCredentials({ endpoint: endpoint.trim(), token: token.trim() }); + if (controller.signal.aborted) { + connection.close(); + return; + } + request.current = null; + props.onConnect(connection); + } catch (cause) { + connection?.close(); + if (!controller.signal.aborted) + setError( + cause instanceof Error + ? cause.message + : "Could not connect. Check the server URL and token." + ); + } finally { + if (!controller.signal.aborted) { + request.current = null; + setBusy(false); + } + } + } + + return ( + + + + + xum. + + + {"Your agents.\nWithin reach."} + + Connect to your Xum server to pick up a conversation, review changes, or start + something new. + + + {loading ? ( + + ) : ( + <> + + { + if (endpoint.trim() && token.trim()) return submit(); + }} + /> + {error && {error}} + + + )} + + + + {Platform.OS === "web" + ? "Your token stays in this tab’s memory. It is never saved in browser storage." + : "Your connection is saved in this device’s secure credential storage."}{" "} + Use a trusted HTTPS server; unencrypted connections are only for local development. + + + + + + ); +} + +const styles = StyleSheet.create({ + page: { flexGrow: 1, justifyContent: "center", padding: 28 }, + form: { gap: 24, maxWidth: 420, width: "100%", alignSelf: "center" }, + wordmark: { fontSize: 38, fontWeight: "700", letterSpacing: -2, color: colors.bright }, + title: { + color: colors.bright, + fontSize: 30, + lineHeight: 38, + fontWeight: "600", + letterSpacing: -0.8, + }, +}); diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx new file mode 100644 index 00000000000..f94cca62874 --- /dev/null +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -0,0 +1,345 @@ +import { useEffect, useRef, useState } from "react"; +import { + FlatList, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + GitCompareArrows, + Menu, + Square, +} from "lucide-react-native"; +import type { MobileClient } from "../api"; +import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; +import type { MuxMessage } from "../../../../src/common/types/message"; +import { IconButton, Loading, Notice } from "../components/Controls"; +import { Message } from "../components/Message"; +import { useConversation } from "../useConversation"; +import { resolveSettings } from "../settings"; +import type { ChatSettings } from "../settings"; +import { ModelSettings } from "./ModelSettings"; +import { colors, layout } from "../theme"; + +export function ConversationScreen(props: { + client: MobileClient; + workspace: FrontendWorkspaceMetadata; + onMenu?: () => void; + onChanges: () => void; +}) { + const { transcript, settings, error, retry } = useConversation(props.client, props.workspace.id); + const [overrides, setOverrides] = useState(null); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + const [showSettings, setShowSettings] = useState(false); + const [atBottom, setAtBottom] = useState(true); + const list = useRef>(null); + const controller = useRef(new AbortController()); + const pending = useRef(false); + // This component is keyed by workspace ID: both subscription and in-flight actions + // belong to one workspace, and a switch cannot expose the previous draft/history. + useEffect(() => { + const abort = new AbortController(); + controller.current = abort; + return () => abort.abort(); + }, []); + const agentId = props.workspace.agentId ?? "exec"; + const options = + overrides ?? (settings ? resolveSettings(props.workspace, settings, agentId) : null); + const ready = transcript.caughtUp && !error && settings !== null; + const running = transcript.streaming; + + async function send() { + if (!ready || !options?.model || !draft.trim() || pending.current || running) return; + pending.current = true; + setBusy(true); + setActionError(null); + const message = draft; + const signal = controller.current.signal; + try { + const result = await props.client.workspace.sendMessage( + { workspaceId: props.workspace.id, message, options }, + { signal } + ); + if (signal.aborted) return; + if (!result.success) + throw new Error( + typeof result.error === "string" ? result.error : JSON.stringify(result.error) + ); + setDraft((current) => (current === message ? "" : current)); + list.current?.scrollToEnd({ animated: true }); + } catch (cause) { + if (!signal.aborted) + setActionError( + `${cause instanceof Error ? cause.message : "Message could not be sent."} If the connection was lost, reload history before retrying to avoid sending twice.` + ); + } finally { + pending.current = false; + if (!signal.aborted) setBusy(false); + } + } + + async function interrupt() { + if (!ready || pending.current) return; + pending.current = true; + setBusy(true); + setActionError(null); + const signal = controller.current.signal; + try { + const result = await props.client.workspace.interruptStream( + { workspaceId: props.workspace.id }, + { signal } + ); + if (!result.success) throw new Error(result.error); + } catch (cause) { + if (!signal.aborted) + setActionError(cause instanceof Error ? cause.message : "Could not interrupt the agent."); + } finally { + pending.current = false; + if (!signal.aborted) setBusy(false); + } + } + + async function answer(toolCallId: string, answers: Record) { + if (!ready) throw new Error("Reconnect before answering."); + const result = await props.client.workspace.answerAskUserQuestion( + { workspaceId: props.workspace.id, toolCallId, answers }, + { signal: controller.current.signal } + ); + if (!result.success) throw new Error(result.error); + } + + return ( + + + {props.onMenu && } + + + {props.workspace.title ?? props.workspace.name} + + + {props.workspace.kind === "scratch" ? "Scratch chat" : props.workspace.name} ·{" "} + {props.workspace.runtimeConfig.type} + + + + + message.id} + contentContainerStyle={styles.messages} + keyboardShouldPersistTaps="handled" + onScroll={(event) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + setAtBottom(contentSize.height - layoutMeasurement.height - contentOffset.y < 80); + }} + scrollEventThrottle={100} + onContentSizeChange={() => { + if (atBottom) list.current?.scrollToEnd({ animated: false }); + }} + renderItem={({ item }) => ( + + )} + ListEmptyComponent={ + !ready && !error ? ( + + ) : error ? null : ( + + What’s on your mind? + + Ask a question, plan a change, or let an agent take it from here. + + + ) + } + ListFooterComponent={ + + {error && {error}} + {transcript.error && {transcript.error}} + {running && ( + Agent is working… + )} + + } + /> + {!atBottom && ( + + list.current?.scrollToEnd({ animated: true })} + /> + + )} + + {actionError && ( + { + setActionError(null); + retry(); + }} + > + {actionError} + + )} + + + + setShowSettings(true)} + style={styles.modelButton} + > + + + {options?.agentId ?? "Agent"} + + {" "} + · {options?.model.split(":").slice(1).join(":") || "Choose model"} + + + + + + + + + + + + {!ready + ? "Syncing required before sending" + : running + ? "Running on your server" + : options?.thinkingLevel + ? `${options.thinkingLevel} thinking · Runs on your server` + : "Runs on your server"} + + + {showSettings && settings && options && ( + setShowSettings(false)} + onSave={(value) => { + setOverrides(value); + setShowSettings(false); + }} + /> + )} + + ); +} + +const styles = StyleSheet.create({ + header: { + minHeight: 68, + paddingHorizontal: 12, + flexDirection: "row", + alignItems: "center", + gap: 8, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + title: { color: colors.bright, fontSize: 15, fontWeight: "600" }, + messages: { + padding: 18, + paddingBottom: 28, + width: "100%", + maxWidth: 820, + alignSelf: "center", + flexGrow: 1, + }, + empty: { flex: 1, paddingVertical: 60, alignItems: "center", justifyContent: "center", gap: 12 }, + emptyTitle: { color: colors.bright, fontSize: 24, fontWeight: "500", letterSpacing: -0.6 }, + composerWrap: { + paddingHorizontal: 12, + paddingTop: 8, + gap: 8, + width: "100%", + maxWidth: 820, + alignSelf: "center", + }, + composer: { + borderRadius: 14, + padding: 10, + backgroundColor: colors.panel, + borderColor: colors.border, + borderWidth: 1, + }, + input: { + color: colors.bright, + fontSize: 15, + lineHeight: 23, + minHeight: 64, + maxHeight: 160, + textAlignVertical: "top", + padding: 4, + }, + modelButton: { + minHeight: 44, + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 4, + flexShrink: 1, + }, + send: { borderRadius: 9, backgroundColor: colors.elevated, marginLeft: 6 }, + status: { color: colors.dim, fontSize: 11, textAlign: "center", paddingBottom: 8 }, + latest: { + position: "absolute", + bottom: 190, + right: 24, + backgroundColor: colors.elevated, + borderRadius: 24, + }, +}); diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx new file mode 100644 index 00000000000..02f79e2cd4f --- /dev/null +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -0,0 +1,204 @@ +import { useEffect, useRef, useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import { Folder, MessageSquare } from "lucide-react-native"; +import type { MobileClient } from "../api"; +import type { Projects } from "../useProjects"; +import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; +import { Button, Field, Loading, Notice, Sheet } from "../components/Controls"; +import { colors, layout } from "../theme"; + +export function CreateWorkspace(props: { + client: MobileClient; + projects: Projects; + onCreated: (workspace: FrontendWorkspaceMetadata) => void; + onClose: () => void; +}) { + const [project, setProject] = useState(null); + return ( + + + Start a scratch conversation or an isolated worktree in an existing server project. + + setProject(null)} + style={[ + layout.row, + { + padding: 12, + minHeight: 48, + borderRadius: 8, + backgroundColor: project === null ? colors.elevated : colors.panel, + }, + ]} + > + + Scratch chat + + {props.projects.map(([path, config]) => ( + setProject(path)} + style={[ + layout.row, + { + padding: 12, + minHeight: 48, + borderRadius: 8, + backgroundColor: project === path ? colors.elevated : colors.panel, + }, + ]} + > + + + {config.displayName ?? path.split(/[\\/]/).at(-1)} + + + ))} + + + ); +} + +function CreateForm(props: { + client: MobileClient; + project: string | null; + onCreated: (workspace: FrontendWorkspaceMetadata) => void; +}) { + const [title, setTitle] = useState(""); + const [branch, setBranch] = useState(""); + const [trunk, setTrunk] = useState(""); + const [branches, setBranches] = useState([]); + const [loading, setLoading] = useState(props.project !== null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + const pending = useRef(false); + const controller = useRef(new AbortController()); + useEffect(() => { + const abort = new AbortController(); + controller.current = abort; + if (props.project) { + setLoading(true); + props.client.projects + .listBranches({ projectPath: props.project }, { signal: abort.signal }) + .then((result) => { + if (abort.signal.aborted) return; + setBranches(result.branches); + setTrunk(result.recommendedTrunk ?? ""); + setError(null); + }) + .catch((cause: unknown) => { + if (!abort.signal.aborted) + setError(cause instanceof Error ? cause.message : "Could not load branches."); + }) + .finally(() => { + if (!abort.signal.aborted) setLoading(false); + }); + } + return () => abort.abort(); + }, [props.client, props.project, generation]); + + async function create() { + if (pending.current) return; + pending.current = true; + setBusy(true); + setError(null); + const signal = controller.current.signal; + try { + const result = props.project + ? await props.client.workspace.create( + { + projectPath: props.project, + branchName: branch.trim() || undefined, + trunkBranch: trunk.trim(), + title: title.trim() || undefined, + // Omission selects the server's worktree default and server-owned src directory. + }, + { signal } + ) + : await props.client.workspace.createScratch( + { title: title.trim() || undefined }, + { signal } + ); + if (signal.aborted) return; + if (!result.success) throw new Error(result.error); + props.onCreated(result.metadata); + } catch (cause) { + if (!signal.aborted) + setError( + cause instanceof Error + ? cause.message + : "Could not create workspace. Refresh the list before retrying if the connection dropped." + ); + } finally { + pending.current = false; + if (!signal.aborted) setBusy(false); + } + } + return ( + + + {props.project && ( + <> + + + {branches.length > 0 && ( + + {branches.slice(0, 8).map((name) => ( + setTrunk(name)} + style={{ + padding: 12, + minHeight: 44, + borderRadius: 8, + backgroundColor: colors.panel, + }} + > + {name} + + ))} + + )} + + )} + {loading && } + {error && setGeneration((value) => value + 1)}>{error}} + + + ); +} diff --git a/packages/mobile/src/screens/ModelSettings.tsx b/packages/mobile/src/screens/ModelSettings.tsx new file mode 100644 index 00000000000..62e23fc5328 --- /dev/null +++ b/packages/mobile/src/screens/ModelSettings.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import { Check } from "lucide-react-native"; +import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; +import { Button, Field, Sheet } from "../components/Controls"; +import { modelChoices, resolveSettings, thinkingLevels } from "../settings"; +import type { ChatSettings, SettingsData } from "../settings"; +import { colors, layout } from "../theme"; + +export function ModelSettings(props: { + value: ChatSettings; + data: SettingsData; + workspace: FrontendWorkspaceMetadata; + onSave: (value: ChatSettings) => void; + onClose: () => void; +}) { + const [value, setValue] = useState(props.value); + const [query, setQuery] = useState(""); + const models = modelChoices(props.data, value.model).filter((model) => + model.toLowerCase().includes(query.toLowerCase()) + ); + return ( + + + Used for your next message. Providers and runtime configuration are managed on your Xum + server. + + AGENT + + {props.data.agents + .filter((agent) => agent.uiSelectable) + .map((agent) => ( + setValue(resolveSettings(props.workspace, props.data, agent.id))} + /> + ))} + + MODEL + + {models.map((model) => ( + setValue({ ...value, model })} + /> + ))} + setValue({ ...value, model })} + placeholder="provider:model" + /> + + Enter a provider:model ID if it is not listed. Availability and reasoning support are + validated by the server. + + THINKING + + setValue({ ...value, thinkingLevel: undefined })} + /> + {thinkingLevels.map((level) => ( + setValue({ ...value, thinkingLevel: level })} + /> + ))} + + + + ); +} + +function Choice(props: { + title: string; + description?: string; + selected: boolean; + onPress: () => void; +}) { + return ( + + + + {props.title} + + {props.description && {props.description}} + + {props.selected && } + + ); +} diff --git a/packages/mobile/src/screens/Navigator.tsx b/packages/mobile/src/screens/Navigator.tsx new file mode 100644 index 00000000000..b70c1e0457a --- /dev/null +++ b/packages/mobile/src/screens/Navigator.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { + Folder, + GitBranch, + MessageSquare, + Plus, + RefreshCw, + Settings, + X, +} from "lucide-react-native"; +import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; +import type { Projects } from "../useProjects"; +import { IconButton, Loading, Notice } from "../components/Controls"; +import { colors, layout } from "../theme"; + +export function Navigator(props: { + projects: Projects; + workspaces: FrontendWorkspaceMetadata[]; + selectedId?: string; + loading: boolean; + error: string | null; + onRetry: () => void; + onSelect: (workspace: FrontendWorkspaceMetadata) => void; + onCreate: () => void; + onSettings: () => void; + onClose?: () => void; +}) { + const [collapsed, setCollapsed] = useState>(() => new Set()); + const groups = new Map(); + for (const [path, config] of props.projects) + groups.set(path, { + name: config.displayName ?? path.split(/[\\/]/).filter(Boolean).at(-1) ?? path, + workspaces: [], + }); + for (const workspace of props.workspaces) { + const key = workspace.kind === "scratch" ? "scratch" : workspace.projectPath; + const group = groups.get(key) ?? { + name: workspace.kind === "scratch" ? "Scratch chats" : workspace.projectName, + workspaces: [], + }; + group.workspaces.push(workspace); + groups.set(key, group); + } + return ( + + + + xum. + + + + + {props.onClose && ( + + )} + + + + {props.error && {props.error}} + {props.loading && } + {!props.loading && groups.size === 0 && ( + + A little room to think. + + Create a scratch chat, or add a project from Xum desktop to get started. + + + )} + {[...groups].map(([key, group]) => ( + + + setCollapsed((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }) + } + style={styles.group} + > + {key === "scratch" ? ( + + ) : ( + + )} + + {group.name} + + {group.workspaces.length} + + {!collapsed.has(key) && + group.workspaces + .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? "")) + .map((workspace) => ( + props.onSelect(workspace)} + style={({ pressed }) => [ + styles.workspace, + workspace.id === props.selectedId && styles.selected, + pressed && { opacity: 0.7 }, + ]} + > + + + + {workspace.title ?? workspace.name} + + + {workspace.name} + + + + ))} + + ))} + + + + Settings & connection + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: colors.panel, + borderRightColor: colors.border, + borderRightWidth: 1, + }, + top: { + minHeight: 68, + paddingHorizontal: 16, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + brand: { color: colors.bright, fontWeight: "700", letterSpacing: -1, fontSize: 26 }, + group: { + flexDirection: "row", + gap: 8, + alignItems: "center", + minHeight: 44, + paddingHorizontal: 8, + }, + workspace: { + flexDirection: "row", + alignItems: "center", + gap: 10, + padding: 12, + minHeight: 60, + borderRadius: 8, + marginBottom: 3, + }, + selected: { backgroundColor: colors.elevated }, + workspaceTitle: { color: colors.text, fontSize: 14, lineHeight: 21 }, + footer: { + flexDirection: "row", + alignItems: "center", + gap: 10, + minHeight: 60, + padding: 16, + borderTopWidth: 1, + borderTopColor: colors.border, + }, +}); diff --git a/packages/mobile/src/screens/SettingsScreen.tsx b/packages/mobile/src/screens/SettingsScreen.tsx new file mode 100644 index 00000000000..9e0e1da2693 --- /dev/null +++ b/packages/mobile/src/screens/SettingsScreen.tsx @@ -0,0 +1,57 @@ +import { Platform, ScrollView, Text, View } from "react-native"; +import { LogOut, Monitor, ShieldCheck } from "lucide-react-native"; +import { Button, Header, Notice } from "../components/Controls"; +import { colors, layout } from "../theme"; + +export function SettingsScreen(props: { + endpoint: string; + onDisconnect: () => void; + onBack: () => void; + error: string | null; + busy: boolean; +}) { + return ( + +
+ + CONNECTION + + + + Your Xum server + + + {props.endpoint} + + + {Platform.OS === "web" + ? "Authentication is held in memory for this tab only." + : "Authentication is kept in this device’s secure credential storage."} + + + ON THIS DEVICE + + + + A companion to your workspace + + + Agents, files, and commands run on your connected Xum server, not on this device. You + can chat, interrupt an agent, create a worktree or scratch chat, and read tracked + changes here. + + + Use Xum desktop for provider credentials, runtime provisioning, terminals, desktop + control, file editing, and project administration. Image and file attachments are + currently displayed as filenames only. + + + {props.error && {props.error}} + + Disconnecting does not stop agents running on the server. + + + ); +} diff --git a/packages/mobile/src/settings.ts b/packages/mobile/src/settings.ts new file mode 100644 index 00000000000..e57c09de952 --- /dev/null +++ b/packages/mobile/src/settings.ts @@ -0,0 +1,57 @@ +import type { MobileClient } from "./api"; +import type { FrontendWorkspaceMetadata } from "../../../src/common/types/workspace"; +import type { SendMessageOptions } from "../../../src/common/orpc/types"; +import type { ThinkingLevel } from "../../../src/common/types/thinking"; + +export type SettingsData = { + config: Awaited>; + providers: Awaited>; + agents: Awaited>; +}; +export type ChatSettings = Pick< + SendMessageOptions, + "model" | "agentId" | "thinkingLevel" | "reasoningMode" +>; +export const thinkingLevels: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh", "max"]; + +export function resolveSettings( + workspace: FrontendWorkspaceMetadata, + data: SettingsData, + agentId: string +): ChatSettings { + const workspaceDefaults = + workspace.aiSettingsByAgent?.[agentId] ?? + (workspace.agentId === agentId ? workspace.aiSettings : undefined); + const globalDefaults = data.config.agentAiDefaults[agentId]; + const agentDefaults = data.agents.find((agent) => agent.id === agentId)?.aiDefaults; + return { + agentId, + model: + workspaceDefaults?.model ?? + globalDefaults?.modelString ?? + agentDefaults?.model ?? + data.config.defaultModel ?? + "", + thinkingLevel: + workspaceDefaults?.thinkingLevel ?? + globalDefaults?.thinkingLevel ?? + agentDefaults?.thinkingLevel, + reasoningMode: workspaceDefaults?.reasoningMode ?? globalDefaults?.reasoningMode, + }; +} + +export function modelChoices(data: SettingsData, currentModel: string): string[] { + const models = new Set(); + if (currentModel) models.add(currentModel); + if (data.config.defaultModel) models.add(data.config.defaultModel); + for (const [provider, config] of Object.entries(data.providers)) { + if (!config.isEnabled || !config.isConfigured) continue; + for (const entry of [...(config.models ?? []), ...(config.discoveredModels ?? [])]) { + const id = typeof entry === "string" ? entry : entry.id; + models.add(`${provider}:${id}`); + } + } + return [...models].filter( + (model) => model === currentModel || !data.config.hiddenModels?.includes(model) + ); +} diff --git a/packages/mobile/src/theme.ts b/packages/mobile/src/theme.ts new file mode 100644 index 00000000000..6272efdbcd2 --- /dev/null +++ b/packages/mobile/src/theme.ts @@ -0,0 +1,30 @@ +import { Platform, StyleSheet } from "react-native"; + +// Native equivalents of the shared dark surface/content tokens in globals.css. +export const colors = { + background: "hsl(240, 10%, 4%)", + panel: "hsl(240, 6%, 10%)", + elevated: "hsl(240, 4%, 16%)", + border: "#262626", + text: "hsl(0, 0%, 83%)", + bright: "hsl(0, 0%, 100%)", + muted: "hsl(240, 5%, 65%)", + dim: "hsl(240, 5%, 34%)", + accent: "hsl(268.56, 90%, 68%)", + plan: "hsl(210, 70%, 68%)", + danger: "hsl(0, 91%, 71%)", + success: "hsl(142, 76%, 46%)", + user: "hsla(0, 0%, 100%, 0.06)", +}; + +export const mono = Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" }); +export const layout = StyleSheet.create({ + fill: { flex: 1, backgroundColor: colors.background }, + row: { flexDirection: "row", alignItems: "center", gap: 8 }, + text: { color: colors.text, fontSize: 15, lineHeight: 23 }, + muted: { color: colors.muted, fontSize: 13, lineHeight: 20 }, + title: { color: colors.bright, fontSize: 20, fontWeight: "600" }, + label: { color: colors.muted, fontSize: 12, fontWeight: "600", letterSpacing: 1 }, + content: { padding: 20, gap: 20, width: "100%", maxWidth: 760, alignSelf: "center" }, + divider: { height: 1, backgroundColor: colors.border }, +}); diff --git a/packages/mobile/src/useConversation.ts b/packages/mobile/src/useConversation.ts new file mode 100644 index 00000000000..26287542553 --- /dev/null +++ b/packages/mobile/src/useConversation.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; +import type { MobileClient } from "./api"; +import { applyChatEvent, createTranscriptState } from "./transcript"; +import type { SettingsData } from "./settings"; + +export function useConversation(client: MobileClient, workspaceId: string) { + const [transcript, setTranscript] = useState(createTranscriptState); + const [settings, setSettings] = useState(null); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + useEffect(() => { + const controller = new AbortController(); + setTranscript(createTranscriptState()); + setSettings(null); + setError(null); + async function subscribe() { + const [config, providers, agents] = await Promise.all([ + client.config.getConfig(undefined, { signal: controller.signal }), + client.providers.getConfig(undefined, { signal: controller.signal }), + client.agents.list({ workspaceId }, { signal: controller.signal }), + ]); + if (controller.signal.aborted) return; + setSettings({ config, providers, agents }); + const events = await client.workspace.onChat( + { workspaceId, mode: { type: "full" } }, + { signal: controller.signal } + ); + for await (const event of events) { + if (controller.signal.aborted) return; + setTranscript((current) => applyChatEvent(current, event)); + } + if (!controller.signal.aborted) + throw new Error( + "Conversation disconnected. Retry to reload the full history before sending." + ); + } + subscribe().catch((cause: unknown) => { + if (!controller.signal.aborted) + setError( + cause instanceof Error + ? cause.message + : "Could not load the conversation. Retry to reconnect." + ); + }); + return () => controller.abort(); + }, [client, workspaceId, generation]); + return { transcript, settings, error, retry: () => setGeneration((value) => value + 1) }; +} diff --git a/packages/mobile/src/useProjects.ts b/packages/mobile/src/useProjects.ts new file mode 100644 index 00000000000..e9b2badc51a --- /dev/null +++ b/packages/mobile/src/useProjects.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import type { MobileClient } from "./api"; +import type { FrontendWorkspaceMetadata } from "../../../src/common/types/workspace"; +import { isWorkspaceArchived } from "../../../src/common/utils/archive"; + +export type Projects = Awaited>; +export function useProjects(client: MobileClient) { + const [projects, setProjects] = useState([]); + const [workspaces, setWorkspaces] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + async function load() { + // Subscribe before listing so metadata changes during the snapshot are not lost. + const events = await client.workspace.onMetadata(undefined, { signal: controller.signal }); + const [projectList, workspaceList] = await Promise.all([ + client.projects.list(undefined, { signal: controller.signal }), + client.workspace.list(undefined, { signal: controller.signal }), + ]); + if (controller.signal.aborted) return; + setProjects(projectList); + setWorkspaces(workspaceList); + setLoading(false); + for await (const event of events) { + if (controller.signal.aborted) return; + setWorkspaces((current) => { + const rest = current.filter((workspace) => workspace.id !== event.workspaceId); + return event.metadata && + !isWorkspaceArchived(event.metadata.archivedAt, event.metadata.unarchivedAt) + ? [...rest, event.metadata] + : rest; + }); + } + if (!controller.signal.aborted) + throw new Error("Workspace updates disconnected. Refresh the list to reconnect."); + } + load().catch((cause: unknown) => { + if (!controller.signal.aborted) { + setError(cause instanceof Error ? cause.message : "Could not load workspaces."); + setLoading(false); + } + }); + return () => controller.abort(); + }, [client, generation]); + return { projects, workspaces, loading, error, retry: () => setGeneration((value) => value + 1) }; +} From 1b17b84703215c555372979288c568ae639afe13 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 15:27:32 +0000 Subject: [PATCH 02/84] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20mobile=20rem?= =?UTF-8?q?ote=20transport=20and=20transcript=20reducer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a single authenticated, owned WebSocket connection for unary RPC and subscriptions, with endpoint validation, cancellation, timeout, and no retries. Reduce real chat events into authoritative, immutable mobile transcript state. Validate endpoint security, replay/interruption/truncation behavior, and live oRPC socket authentication, subscription delivery, and mutation lifecycle. --- packages/mobile/src/api.test.ts | 215 +++++++++++++++++++ packages/mobile/src/api.ts | 94 +++++++++ packages/mobile/src/endpoint.test.ts | 62 ++++++ packages/mobile/src/endpoint.ts | 42 ++++ packages/mobile/src/transcript.test.ts | 222 ++++++++++++++++++++ packages/mobile/src/transcript.ts | 273 +++++++++++++++++++++++++ 6 files changed, 908 insertions(+) create mode 100644 packages/mobile/src/api.test.ts create mode 100644 packages/mobile/src/api.ts create mode 100644 packages/mobile/src/endpoint.test.ts create mode 100644 packages/mobile/src/endpoint.ts create mode 100644 packages/mobile/src/transcript.test.ts create mode 100644 packages/mobile/src/transcript.ts diff --git a/packages/mobile/src/api.test.ts b/packages/mobile/src/api.test.ts new file mode 100644 index 00000000000..a2e733fb72b --- /dev/null +++ b/packages/mobile/src/api.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "bun:test"; +import { ORPCError, os } from "@orpc/server"; +import { RPCHandler } from "@orpc/server/websocket"; +import { z } from "zod"; +import { once } from "node:events"; +import assert from "node:assert/strict"; +import { WebSocketServer } from "ws"; +import { connect } from "./api"; +import type { WorkspaceChatMessage } from "./transcript"; + +async function expectFailure(promise: Promise, message?: string): Promise { + const error = await promise.catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + if (message) expect(String(error)).toContain(message); +} + +async function serverFixture(stallProbe = false) { + const token = "private token/+?"; + let calls = 0; + let upgrades = 0; + let mutations = 0; + let onOpen: () => void = () => undefined; + let onClose: () => void = () => undefined; + const opened = new Promise((resolve) => { + onOpen = resolve; + }); + const closed = new Promise((resolve) => { + onClose = resolve; + }); + const procedure = os.$context<{ authenticated: boolean }>().use(({ context, next }) => { + if (!context.authenticated) throw new ORPCError("UNAUTHORIZED"); + return next(); + }); + const handler = new RPCHandler({ + workspace: { + list: procedure.handler(async ({ signal }) => { + calls++; + if (stallProbe) + await new Promise((resolve) => { + if (signal?.aborted) resolve(); + else signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + return []; + }), + interruptStream: procedure.input(z.object({ workspaceId: z.string() })).handler(() => { + mutations++; + throw new ORPCError("INTERNAL_SERVER_ERROR"); + }), + onChat: procedure + .input(z.object({ workspaceId: z.string(), mode: z.object({ type: z.literal("full") }) })) + .handler(async function* (): AsyncGenerator { + yield Promise.resolve({ + type: "message", + id: "one", + role: "user", + parts: [{ type: "text", text: "hello" }], + }); + yield { type: "caught-up", replay: "full" }; + }), + }, + }); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0, path: "/proxy/orpc/ws" }); + server.on("connection", (socket, request) => { + upgrades++; + const url = new URL(request.url ?? "", "http://localhost"); + handler.upgrade(socket, { + context: { authenticated: url.searchParams.get("token") === token }, + }); + socket.once("close", onClose); + onOpen(); + }); + await once(server, "listening"); + const address = server.address(); + assert(address && typeof address !== "string", "Test server must bind a TCP port"); + return { + endpoint: `http://127.0.0.1:${address.port}/proxy`, + token, + opened, + closed, + calls: () => calls, + upgrades: () => upgrades, + mutations: () => mutations, + [Symbol.asyncDispose]: async () => { + for (const socket of server.clients) socket.terminate(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +describe("mobile WebSocket connection", () => { + test("authenticates unary probe and streams actual oRPC events through a proxy prefix", async () => { + await using server = await serverFixture(); + const connection = await connect(`${server.endpoint}/`, server.token); + try { + expect(connection.endpoint).toBe(server.endpoint); + expect(server.calls()).toBe(1); + const events: WorkspaceChatMessage[] = []; + const subscription = await connection.client.workspace.onChat({ + workspaceId: "w", + mode: { type: "full" }, + }); + for await (const event of subscription) events.push(event); + expect(events.map((event) => event.type)).toEqual(["message", "caught-up"]); + expect(server.upgrades()).toBe(1); + } finally { + connection.close(); + connection.close(); + } + await server.closed; + await expectFailure(connection.client.workspace.list()); + expect(server.upgrades()).toBe(1); + expect(server.calls()).toBe(1); + }); + + test("does not retry a failed mutation or dispatch mutations after close", async () => { + await using server = await serverFixture(); + const connection = await connect(server.endpoint, server.token); + try { + await expectFailure(connection.client.workspace.interruptStream({ workspaceId: "w" })); + expect(server.mutations()).toBe(1); + } finally { + connection.close(); + } + await server.closed; + await expectFailure( + connection.client.workspace.interruptStream({ workspaceId: "w" }), + "Connection closed." + ); + expect(server.mutations()).toBe(1); + expect(server.upgrades()).toBe(1); + }); + + test("rejects bad auth without exposing token or URL and closes its socket", async () => { + await using server = await serverFixture(); + const secret = "wrong-secret"; + const error = await connect(server.endpoint, secret).catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toContain(secret); + expect(String(error)).not.toContain(server.endpoint); + await server.closed; + expect(server.calls()).toBe(0); + }); + + test("cancellation closes the socket before the WebSocket handshake completes", async () => { + let onRequest: (request: Request) => void = () => undefined; + const requested = new Promise((resolve) => { + onRequest = resolve; + }); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + onRequest(request); + return new Promise((resolve) => { + request.signal.addEventListener( + "abort", + () => resolve(new Response(null, { status: 503 })), + { once: true } + ); + }); + }, + }); + try { + const controller = new AbortController(); + const pending = connect(`http://127.0.0.1:${server.port}`, "secret", { + signal: controller.signal, + }); + const request = await requested; + const disconnected = new Promise((resolve) => { + request.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + controller.abort(); + await expectFailure(pending, "Connection cancelled."); + await disconnected; + } finally { + await server.stop(true); + } + }); + + test("cancellation closes a pending authenticated probe", async () => { + await using server = await serverFixture(true); + const controller = new AbortController(); + const pending = connect(server.endpoint, server.token, { signal: controller.signal }); + await server.opened; + controller.abort("secret cancellation reason"); + await expectFailure(pending, "Connection cancelled."); + await server.closed; + }); + + test("already aborted signal never opens a socket; lifetime abort closes a connected one", async () => { + await using server = await serverFixture(); + const cancelled = new AbortController(); + cancelled.abort(); + await expectFailure( + connect(server.endpoint, server.token, { signal: cancelled.signal }), + "cancelled" + ); + expect(server.upgrades()).toBe(0); + const lifetime = new AbortController(); + const connection = await connect(server.endpoint, server.token, { signal: lifetime.signal }); + lifetime.abort(); + await server.closed; + connection.close(); + expect(server.upgrades()).toBe(1); + }); + + test("a stalled authenticated RPC probe times out and closes its socket", async () => { + await using server = await serverFixture(true); + await expectFailure(connect(server.endpoint, server.token), "Connection timed out."); + await server.closed; + expect(server.upgrades()).toBe(1); + }, 15_000); +}); diff --git a/packages/mobile/src/api.ts b/packages/mobile/src/api.ts new file mode 100644 index 00000000000..9a6790ef7dc --- /dev/null +++ b/packages/mobile/src/api.ts @@ -0,0 +1,94 @@ +import { RPCLink } from "@orpc/client/websocket"; +import { createClient } from "../../../src/common/orpc/client"; +import { normalizeEndpoint } from "./endpoint"; + +export type MobileClient = ReturnType; +export interface MobileConnection { + client: MobileClient; + endpoint: string; + close: () => void; +} + +const CONNECT_TIMEOUT_MS = 10_000; + +/** + * One owned socket for both authenticated unary RPC and subscriptions. Reconnect + * is explicit: never retry a mutation, and reset chat before a new full replay. + * The signal owns the connection lifetime, including the pending handshake. + */ +export async function connect( + endpoint: string, + token: string, + options: { signal?: AbortSignal } = {} +): Promise { + const normalized = normalizeEndpoint(endpoint); + if (!token.trim()) throw new Error("Enter a server token."); + if (options.signal?.aborted) throw new Error("Connection cancelled."); + + const url = new URL(`${normalized}/orpc/ws`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("token", token.trim()); + let socket: WebSocket; + try { + socket = new WebSocket(url.toString()); + socket.binaryType = "arraybuffer"; + } catch { + // Native WebSocket errors may include the credential-bearing URL. + throw new Error("Unable to open a connection to the server."); + } + + const probe = new AbortController(); + let closed = false; + let timedOut = false; + const close = () => { + if (closed) return; + closed = true; + options.signal?.removeEventListener("abort", close); + socket.removeEventListener("close", close); + probe.abort(); + try { + if (socket.readyState < 2) socket.close(); + } catch { + // Some native implementations throw when closing a pending handshake. + // Still close if that handshake subsequently succeeds. + socket.addEventListener("open", () => socket.close(), { once: true }); + } + }; + socket.addEventListener("close", close); + options.signal?.addEventListener("abort", close, { once: true }); + const timeout = setTimeout(() => { + timedOut = true; + close(); + }, CONNECT_TIMEOUT_MS); + + try { + const client = createClient( + new RPCLink({ + connect: () => { + if (closed) throw new Error("Connection closed."); + return socket; + }, + reconnect: { enabled: false }, + // The adapter retains its peer after close; reject before it can queue a + // call that will never receive a response (or replay a mutation). + interceptors: [ + (options) => { + if (closed) throw new Error("Connection closed."); + return options.next(); + }, + ], + }) + ); + // An open handshake alone does not prove RPC authentication succeeded. + await client.workspace.list(undefined, { signal: probe.signal }); + if (closed) throw new Error("Connection closed."); + return { client, close, endpoint: normalized }; + } catch { + close(); + if (options.signal?.aborted) throw new Error("Connection cancelled."); + if (timedOut) throw new Error("Connection timed out."); + throw new Error("Unable to connect. Check the server address and token."); + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/mobile/src/endpoint.test.ts b/packages/mobile/src/endpoint.test.ts new file mode 100644 index 00000000000..cbe475519d0 --- /dev/null +++ b/packages/mobile/src/endpoint.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { isInsecureEndpoint, normalizeEndpoint } from "./endpoint"; + +describe("mobile endpoints", () => { + test("preserves proxy prefixes and normalizes origin and trailing slash", () => { + expect(normalizeEndpoint(" HTTPS://Example.COM:443/@user/workspace/apps/xum/// ")).toBe( + "https://example.com/@user/workspace/apps/xum" + ); + expect(normalizeEndpoint("https://example.com")).toBe("https://example.com"); + }); + + test.each([ + "https://user:secret@example.com", + "https://user@example.com", + "https://@example.com", + "https:////example.com", + "https://example.com\u0000", + "https://example.com?token=secret", + "https://example.com?", + "https://example.com#", + "ftp://example.com", + "ws://localhost", + "file:///tmp", + "example.com", + "https://", + "https://example.com\\@other.com", + "https://exa\nmple.com", + "http://example.com", + "http://172.32.0.1", + "http://192.169.0.1", + "http://10.0.0.1.example.com", + "http://0.0.0.0", + "http://[2001:4860:4860::8888]", + ])("rejects unsafe endpoint %s without echoing input", (input) => { + let error: unknown; + try { + normalizeEndpoint(input); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toContain(input); + expect(String(error)).not.toContain("secret"); + }); + + test.each([ + "localhost", + "127.0.0.1", + "10.0.0.2", + "172.16.0.1", + "172.31.255.254", + "192.168.1.2", + "[::1]", + "[fd00::1]", + "[fe80::1]", + ])("allows explicit local development with a cleartext warning: %s", (host) => { + const endpoint = `http://${host}:3000/proxy`; + expect(normalizeEndpoint(endpoint)).toBe(endpoint); + expect(isInsecureEndpoint(endpoint)).toBe(true); + expect(isInsecureEndpoint(`https://${host}:3000/proxy`)).toBe(false); + }); +}); diff --git a/packages/mobile/src/endpoint.ts b/packages/mobile/src/endpoint.ts new file mode 100644 index 00000000000..a50579f076a --- /dev/null +++ b/packages/mobile/src/endpoint.ts @@ -0,0 +1,42 @@ +function isLocalHost(hostname: string): boolean { + if (hostname === "localhost" || hostname === "[::1]") return true; + // Cleartext is a development-only escape hatch for literal LAN addresses, + // not arbitrary DNS names which could resolve to a public server. + if (/^\[(?:f[cd][\da-f]{2}:|fe[89ab][\da-f]:)/i.test(hostname)) return true; + const octets = hostname.split(".").map(Number); + if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { + return false; + } + const [a, b] = octets; + return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +/** The endpoint is a server base URL, including any reverse-proxy path prefix. */ +export function normalizeEndpoint(input: string): string { + const value = input.trim(); + // Reject even empty ?/#, and URL-parser repairs that could hide credentials or + // silently change the host/path. Errors must never echo user input or tokens. + if (!/^https?:\/\/[^/]/i.test(value) || /[\s\p{Cc}\\?#]/u.test(value)) { + throw new Error("Enter an HTTP(S) server URL without credentials, query, or fragment."); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Invalid server URL."); + } + if (url.username || url.password || value.split("/")[2].includes("@")) { + throw new Error("Enter the server token separately, not in the URL."); + } + if (url.protocol === "http:" && !isLocalHost(url.hostname)) { + throw new Error( + "Remote servers require HTTPS. HTTP is only allowed for localhost or private LAN addresses." + ); + } + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +} + +/** Show a warning: native HTTP LAN development sends the token without TLS. */ +export function isInsecureEndpoint(endpoint: string): boolean { + return normalizeEndpoint(endpoint).startsWith("http:"); +} diff --git a/packages/mobile/src/transcript.test.ts b/packages/mobile/src/transcript.test.ts new file mode 100644 index 00000000000..b97e05034da --- /dev/null +++ b/packages/mobile/src/transcript.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test"; +import { applyChatEvent, createTranscriptState, type WorkspaceChatMessage } from "./transcript"; + +const start: Extract = { + type: "stream-start", + workspaceId: "w", + messageId: "a", + model: "test:model", + historySequence: 2, + startTime: 10, +}; +const delta = ( + text: string, + type: "stream-delta" | "reasoning-delta" = "stream-delta" +): WorkspaceChatMessage => ({ + type, + workspaceId: "w", + messageId: "a", + delta: text, + tokens: 1, + timestamp: 12, +}); +const row = (id: string, historySequence: number, text: string): WorkspaceChatMessage => ({ + type: "message", + id, + role: "user", + parts: [{ type: "text", text }], + metadata: { historySequence }, +}); +const replay = (...events: WorkspaceChatMessage[]) => + events.reduce(applyChatEvent, createTranscriptState()); +const tool: Extract = { + type: "tool-call-start", + workspaceId: "w", + messageId: "a", + toolCallId: "t", + toolName: "bash", + args: { script: "pwd" }, + tokens: 3, + timestamp: 13, +}; +const toolEnd: Extract = { + type: "tool-call-end", + workspaceId: "w", + messageId: "a", + toolCallId: "t", + toolName: "bash", + result: { output: "/project" }, + timestamp: 14, +}; + +describe("mobile transcript", () => { + test("replaces authoritative snapshots by ID and sorts by server sequence", () => { + const state = replay(row("b", 3, "later"), row("u", 1, "old"), row("u", 1, "edited"), { + type: "caught-up", + replay: "full", + hasOlderHistory: true, + }); + expect(state.messages.map((m) => m.id)).toEqual(["u", "b"]); + expect(state.messages[0].parts).toEqual([{ type: "text", text: "edited" }]); + expect(state.caughtUp).toBe(true); + expect(state.hasOlderHistory).toBe(true); + expect(state.streaming).toBe(false); + }); + + test("preserves text/reasoning/tool temporal order without mutating prior state", () => { + const before = replay(row("u", 1, "hi"), start, delta("think", "reasoning-delta")); + const snapshot = structuredClone(before); + const state = [ + delta(" more", "reasoning-delta"), + { type: "reasoning-end", workspaceId: "w", messageId: "a" } satisfies WorkspaceChatMessage, + delta("Hello"), + delta(" world"), + tool, + toolEnd, + delta(" done"), + ].reduce(applyChatEvent, before); + expect(before).toEqual(snapshot); + expect(state.streaming).toBe(true); + expect(state.messages[1].parts).toMatchObject([ + { type: "reasoning", text: "think more" }, + { type: "text", text: "Hello world" }, + { + type: "dynamic-tool", + toolCallId: "t", + input: { script: "pwd" }, + state: "output-available", + output: { output: "/project" }, + }, + { type: "text", text: " done" }, + ]); + }); + + test("tool argument deltas do not replace parsed args, duplicate starts do not duplicate tools", () => { + const state = replay( + start, + { ...tool, type: "tool-call-delta", delta: '{"script":' }, + tool, + { + type: "tool-call-execution-start", + workspaceId: "w", + messageId: "a", + toolCallId: "t", + timestamp: 20, + }, + toolEnd, + tool + ); + expect(state.messages[0].parts).toHaveLength(1); + expect(state.messages[0].parts[0]).toMatchObject({ + input: { script: "pwd" }, + state: "output-available", + executionStartedAt: 20, + }); + }); + + test("nested tools remain in their parent and retain completed output", () => { + const state = replay( + start, + tool, + { ...tool, toolCallId: "child", parentToolCallId: "t" }, + { ...toolEnd, toolCallId: "child", parentToolCallId: "t" } + ); + expect(state.messages[0].parts).toHaveLength(1); + expect(state.messages[0].parts[0]).toMatchObject({ + nestedCalls: [{ toolCallId: "child", state: "output-available", output: toolEnd.result }], + }); + }); + + test("stream-end replaces deltas with authoritative parts and preserves ordering metadata", () => { + const end: WorkspaceChatMessage = { + type: "stream-end", + workspaceId: "w", + messageId: "a", + metadata: { model: "other:model" }, + parts: [{ type: "text", text: "final" }], + }; + const state = replay(start, delta("draft"), end, delta("late")); + expect(state.messages[0].parts).toEqual(end.parts); + expect(state.messages[0].metadata).toMatchObject({ + historySequence: 2, + model: "other:model", + partial: false, + }); + expect(state.streaming).toBe(false); + expect(replay(end).messages[0].parts).toEqual(end.parts); + }); + + test("interruption keeps partial text, abandonment removes it", () => { + const abort: WorkspaceChatMessage = { + type: "stream-abort", + workspaceId: "w", + messageId: "a", + abortReason: "user", + }; + const state = replay(start, delta("partial"), tool, abort); + expect(state.streaming).toBe(false); + expect(state.messages[0].metadata?.partial).toBe(true); + expect(state.messages[0].parts).toHaveLength(2); + expect(applyChatEvent(state, { ...abort, abandonPartial: true }).messages).toEqual([]); + }); + + test("errors terminate output, later start clears the error, stale terminal events do not stop a newer stream", () => { + const state = replay(start, delta("partial"), { + type: "stream-error", + messageId: "a", + error: "provider failed", + errorType: "unknown", + }); + expect(state.error).toBe("provider failed"); + expect(state.streaming).toBe(false); + expect(state.messages[0].metadata?.partial).toBe(true); + const next = applyChatEvent(state, { ...start, messageId: "next", historySequence: 3 }); + expect(next.error).toBeNull(); + expect( + applyChatEvent(next, { type: "stream-abort", workspaceId: "w", messageId: "a" }).streaming + ).toBe(true); + }); + + test("full replay resets old history and does not duplicate streamed text on reconnect", () => { + const first = replay(row("deleted", 1, "gone"), start, delta("old")); + expect(first.messages).toHaveLength(2); + const second = replay( + row("a", 2, "partial snapshot"), + { ...start, replay: true }, + delta("fresh"), + { type: "caught-up", replay: "full" } + ); + expect(second.messages.map((m) => m.id)).toEqual(["a"]); + expect(second.messages[0].parts).toMatchObject([{ type: "text", text: "fresh" }]); + expect(second.streaming).toBe(true); + }); + + test("deletion/truncation drops server sequences and the active stream without resurrecting it", () => { + const state = replay( + row("u", 1, "keep"), + start, + delta("remove"), + { type: "delete", historySequences: [2] }, + delta("late") + ); + expect(state.messages.map((m) => m.id)).toEqual(["u"]); + expect(state.streaming).toBe(false); + }); + + test("preparing/completing stay busy and terminal lifecycle is idle", () => { + const state = replay( + { type: "stream-lifecycle", workspaceId: "w", phase: "preparing", hadAnyOutput: false }, + { type: "caught-up", replay: "full" } + ); + expect(state.streaming).toBe(true); + expect(state.caughtUp).toBe(true); + expect( + applyChatEvent(state, { + type: "stream-lifecycle", + workspaceId: "w", + phase: "failed", + hadAnyOutput: false, + }).streaming + ).toBe(false); + }); +}); diff --git a/packages/mobile/src/transcript.ts b/packages/mobile/src/transcript.ts new file mode 100644 index 00000000000..849af1c2025 --- /dev/null +++ b/packages/mobile/src/transcript.ts @@ -0,0 +1,273 @@ +import type { WorkspaceChatMessage } from "../../../src/common/orpc/types"; +import type { MuxMessage, MuxToolPart } from "../../../src/common/types/message"; + +export type { MuxMessage, WorkspaceChatMessage }; +export interface TranscriptState { + messages: MuxMessage[]; + streaming: boolean; + streamingMessageId: string | null; + error: string | null; + caughtUp: boolean; + hasOlderHistory: boolean; +} + +/** Reset before EVERY onChat({mode: {type: "full"}}), including reconnects. */ +export function createTranscriptState(): TranscriptState { + return { + messages: [], + streaming: false, + streamingMessageId: null, + error: null, + caughtUp: false, + hasOlderHistory: false, + }; +} + +function upsert(messages: MuxMessage[], message: MuxMessage): MuxMessage[] { + const index = messages.findIndex((item) => item.id === message.id); + const next = [...messages]; + if (index < 0) next.push(message); + else next[index] = message; + // IDs identify rows; only the server's sequence determines their ordering. + return next.sort( + (a, b) => + (a.metadata?.historySequence ?? Number.MAX_SAFE_INTEGER) - + (b.metadata?.historySequence ?? Number.MAX_SAFE_INTEGER) + ); +} + +function updateMessage( + state: TranscriptState, + id: string, + update: (message: MuxMessage) => MuxMessage +): TranscriptState { + return { + ...state, + messages: state.messages.map((message) => (message.id === id ? update(message) : message)), + }; +} + +function finish(state: TranscriptState, id: string): TranscriptState { + return state.streamingMessageId === null || state.streamingMessageId === id + ? { ...state, streaming: false, streamingMessageId: null } + : state; +} + +type ToolEvent = Extract; + +function applyTool(message: MuxMessage, event: ToolEvent): MuxMessage { + const update = (part?: MuxToolPart): MuxToolPart => { + if (event.type === "tool-call-start") { + return { + type: "dynamic-tool", + state: "input-available", + ...part, + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.args, + timestamp: event.timestamp, + executionStartedAt: event.executionStartedAt ?? part?.executionStartedAt, + }; + } + return { + type: "dynamic-tool", + input: undefined, + timestamp: event.timestamp, + ...part, + toolCallId: event.toolCallId, + toolName: event.toolName, + state: "output-available", + output: event.result, + }; + }; + const parts = [...message.parts]; + const index = parts.findIndex( + (part) => + part.type === "dynamic-tool" && + part.toolCallId === (event.parentToolCallId ?? event.toolCallId) + ); + const part = parts[index]; + if (event.parentToolCallId) { + // Nested PTC calls belong inside their parent, not as duplicate top-level rows. + if (part?.type !== "dynamic-tool") return message; + const nestedCalls = [...(part.nestedCalls ?? [])]; + const nestedIndex = nestedCalls.findIndex((call) => call.toolCallId === event.toolCallId); + const previous = nestedCalls[nestedIndex]; + const nested = { + ...previous, + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.type === "tool-call-start" ? event.args : previous?.input, + timestamp: previous?.timestamp ?? event.timestamp, + state: + event.type === "tool-call-end" + ? ("output-available" as const) + : (previous?.state ?? "input-available"), + ...(event.type === "tool-call-end" ? { output: event.result } : {}), + }; + if (nestedIndex < 0) nestedCalls.push(nested); + else nestedCalls[nestedIndex] = nested; + parts[index] = { ...part, nestedCalls }; + } else if (part?.type === "dynamic-tool") { + parts[index] = update(part); + } else { + parts.push(update()); + } + return { ...message, parts }; +} + +/** Pure, ordered wire-event reducer. UI-only telemetry is deliberately ignored. */ +export function applyChatEvent( + state: TranscriptState, + event: WorkspaceChatMessage +): TranscriptState { + switch (event.type) { + case "message": { + // Snapshots are authoritative replacements, not appended text. This also + // reconciles a persisted final row with its formerly streamed placeholder. + const message: MuxMessage = { + id: event.id, + role: event.role, + parts: event.parts, + metadata: event.metadata, + }; + const next = { ...state, messages: upsert(state.messages, message) }; + return event.metadata?.partial !== true && state.streamingMessageId === event.id + ? finish(next, event.id) + : next; + } + case "caught-up": + return { + ...state, + caughtUp: true, + hasOlderHistory: event.hasOlderHistory ?? state.hasOlderHistory, + }; + case "stream-start": + return { + ...state, + streaming: true, + streamingMessageId: event.messageId, + error: null, + messages: upsert(state.messages, { + id: event.messageId, + role: "assistant", + parts: [], + metadata: { + historySequence: event.historySequence, + timestamp: event.startTime, + model: event.model, + metadataModel: event.metadataModel, + agentId: event.agentId, + mode: event.mode, + thinkingLevel: event.thinkingLevel, + partial: true, + }, + }), + }; + case "stream-delta": + case "reasoning-delta": + if (state.streamingMessageId !== event.messageId) return state; + return updateMessage(state, event.messageId, (message) => { + const type = event.type === "stream-delta" ? "text" : "reasoning"; + const parts = [...message.parts]; + const last = parts[parts.length - 1]; + const signature = + event.type === "reasoning-delta" && event.signature !== undefined + ? { signature: event.signature } + : {}; + if (last?.type === type) { + parts[parts.length - 1] = { ...last, text: last.text + event.delta, ...signature }; + } else { + parts.push({ type, text: event.delta, timestamp: event.timestamp, ...signature }); + } + return { ...message, parts }; + }); + case "tool-call-start": + case "tool-call-end": + return updateMessage(state, event.messageId, (message) => applyTool(message, event)); + case "tool-call-execution-start": + return updateMessage(state, event.messageId, (message) => ({ + ...message, + parts: message.parts.map((part) => + part.type === "dynamic-tool" && part.toolCallId === event.toolCallId + ? { ...part, executionStartedAt: event.timestamp } + : part + ), + })); + case "stream-end": { + const previous = state.messages.find((message) => message.id === event.messageId); + return finish( + { + ...state, + messages: upsert(state.messages, { + id: event.messageId, + role: "assistant", + parts: event.parts, + metadata: { + ...previous?.metadata, + ...event.metadata, + partial: false, + error: undefined, + errorType: undefined, + }, + }), + }, + event.messageId + ); + } + case "stream-abort": { + const next = event.abandonPartial + ? { ...state, messages: state.messages.filter((message) => message.id !== event.messageId) } + : updateMessage(state, event.messageId, (message) => ({ + ...message, + metadata: { ...message.metadata, ...event.metadata, partial: true }, + })); + return finish(next, event.messageId); + } + case "stream-error": + case "error": + return finish( + updateMessage({ ...state, error: event.error }, event.messageId, (message) => ({ + ...message, + metadata: { + ...message.metadata, + partial: true, + error: event.error, + errorType: event.errorType, + }, + })), + event.messageId + ); + case "delete": { + const deleted = new Set(event.historySequences); + const messages = state.messages.filter( + (message) => + message.metadata?.historySequence === undefined || + !deleted.has(message.metadata.historySequence) + ); + const removedActive = + state.streamingMessageId !== null && + !messages.some((message) => message.id === state.streamingMessageId); + return { + ...state, + messages, + ...(removedActive ? { streaming: false, streamingMessageId: null } : {}), + }; + } + case "stream-lifecycle": { + const streaming = + event.phase === "preparing" || event.phase === "streaming" || event.phase === "completing"; + return { + ...state, + streaming, + streamingMessageId: streaming ? state.streamingMessageId : null, + }; + } + // tool-call-delta carries incomplete args; tool-call-start supplies parsed + // authoritative input. reasoning-end carries no text or message completion. + case "tool-call-delta": + case "reasoning-end": + default: + return state; + } +} From 65ac4a4281d8ea9e6605cd30ea9a6b5492c89c68 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 15:39:35 +0000 Subject: [PATCH 03/84] =?UTF-8?q?=F0=9F=A4=96=20fix:=20replace=20mobile=20?= =?UTF-8?q?connections=20on=20explicit=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the selected workspace and draft mounted while replacing the closed connection. Abort all prior-connection work, require fresh full replay before chat writes, and cancel late reconnects when disconnecting or unmounting. Warn explicitly before HTTP sends bearer credentials in plaintext. Validation: five reconnect lifecycle tests (28 assertions), 17 TS/TSX syntax checks, formatting and whitespace. Full RN typecheck and UI evidence remain with parent integration. --- packages/mobile/App.tsx | 36 +++-- packages/mobile/src/screens/ChangesScreen.tsx | 13 +- packages/mobile/src/screens/ConnectScreen.tsx | 15 +- .../mobile/src/screens/ConversationScreen.tsx | 37 +++-- .../mobile/src/screens/CreateWorkspace.tsx | 38 +++-- packages/mobile/src/useConnection.test.ts | 134 ++++++++++++++++++ packages/mobile/src/useConnection.ts | 83 +++++++++++ packages/mobile/src/useConversation.ts | 17 ++- packages/mobile/src/useProjects.ts | 11 +- 9 files changed, 342 insertions(+), 42 deletions(-) create mode 100644 packages/mobile/src/useConnection.test.ts create mode 100644 packages/mobile/src/useConnection.ts diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx index 90fc876d75c..94ef341f29a 100644 --- a/packages/mobile/App.tsx +++ b/packages/mobile/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Modal, StatusBar, Text, useWindowDimensions, View } from "react-native"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { Menu, Plus } from "lucide-react-native"; @@ -12,11 +12,11 @@ import { ChangesScreen } from "./src/screens/ChangesScreen"; import { SettingsScreen } from "./src/screens/SettingsScreen"; import { Button, Header, IconButton, Loading, Notice } from "./src/components/Controls"; import { useProjects } from "./src/useProjects"; +import { useConnection } from "./src/useConnection"; import { colors, layout } from "./src/theme"; export default function App() { const [connection, setConnection] = useState(null); - useEffect(() => () => connection?.close(), [connection]); return ( @@ -34,7 +34,8 @@ export default function App() { function ConnectedApp(props: { connection: Connection; onDisconnect: () => void }) { const { width } = useWindowDimensions(); const wide = width >= 900; - const data = useProjects(props.connection.client); + const session = useConnection(props.connection); + const data = useProjects(session.connection.client, session.signal); const [selectedId, setSelectedId] = useState(null); const [drawer, setDrawer] = useState(false); const [create, setCreate] = useState(false); @@ -43,6 +44,7 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void const [disconnecting, setDisconnecting] = useState(false); const selected = data.workspaces.find((workspace) => workspace.id === selectedId); async function disconnect() { + session.cancel(); setDisconnecting(true); setDisconnectError(null); try { @@ -59,7 +61,9 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void { setSelectedId(workspace.id); setDrawer(false); @@ -67,7 +71,7 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void }} onCreate={() => { setDrawer(false); - setCreate(true); + if (session.ready) setCreate(true); }} onSettings={() => { setDrawer(false); @@ -80,6 +84,8 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void {wide && {navigation}} + {session.reconnecting && } + {session.error && {session.error}} void {selected ? ( setDrawer(true)} onChanges={() => setScreen("changes")} /> @@ -112,7 +121,7 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void {data.loading ? ( ) : data.error ? ( - {data.error} + {data.error} ) : ( <> Make space for your next idea. @@ -120,7 +129,7 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void Select a workspace or start a new conversation. Everything stays on your Xum server. - @@ -132,14 +141,16 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void {screen === "changes" && selected && ( setScreen("chat")} /> )} {screen === "settings" && ( setScreen("chat")} error={disconnectError} @@ -154,8 +165,11 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void )} {create && ( setCreate(false)} onCreated={(workspace) => { setSelectedId(workspace.id); diff --git a/packages/mobile/src/screens/ChangesScreen.tsx b/packages/mobile/src/screens/ChangesScreen.tsx index 0a035bd92fa..203e303d524 100644 --- a/packages/mobile/src/screens/ChangesScreen.tsx +++ b/packages/mobile/src/screens/ChangesScreen.tsx @@ -4,10 +4,13 @@ import { RefreshCw } from "lucide-react-native"; import type { MobileClient } from "../api"; import { Header, IconButton, Loading, Notice } from "../components/Controls"; import { colors, layout, mono } from "../theme"; +import { linkedAbortController } from "../useConnection"; export function ChangesScreen(props: { client: MobileClient; workspaceId: string; + signal: AbortSignal; + onReconnect: () => Promise; onBack: () => void; }) { const [output, setOutput] = useState(null); @@ -18,10 +21,14 @@ export function ChangesScreen(props: { setGeneration((value) => value + 1); } useEffect(() => { - const controller = new AbortController(); + const controller = linkedAbortController(props.signal); setOutput(null); setError(null); setNote(null); + if (controller.signal.aborted) { + setError("Reconnect to load changes."); + return; + } // Fixed argv prevents branch/file names from becoming shell code. Disable external // diff/textconv hooks: this view only reads tracked worktree changes against HEAD. props.client.workspace @@ -59,7 +66,7 @@ export function ChangesScreen(props: { setError(cause instanceof Error ? cause.message : "Could not load changes."); }); return () => controller.abort(); - }, [props.client, props.workspaceId, generation]); + }, [props.client, props.workspaceId, props.signal, generation]); return (
- {error && {error}} + {error && {error}} {output === null && !error && } {note && {note}} {output === "" && No tracked changes against HEAD.} diff --git a/packages/mobile/src/screens/ConnectScreen.tsx b/packages/mobile/src/screens/ConnectScreen.tsx index 74857993ac0..0b0390eb897 100644 --- a/packages/mobile/src/screens/ConnectScreen.tsx +++ b/packages/mobile/src/screens/ConnectScreen.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import { ArrowRight, ShieldCheck } from "lucide-react-native"; import { connect } from "../connection"; +import { isInsecureEndpoint } from "../endpoint"; import { loadCredentials, saveCredentials } from "../credentials"; import { Button, Field, Loading, Notice } from "../components/Controls"; import { colors, layout } from "../theme"; @@ -15,6 +16,12 @@ export function ConnectScreen(props: { onConnect: (connection: Connection) => vo const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const request = useRef(null); + let insecure = false; + try { + insecure = isInsecureEndpoint(endpoint.trim()); + } catch { + /* A partially entered URL is validated on connect, not while typing. */ + } useEffect(() => { let active = true; @@ -117,6 +124,12 @@ export function ConnectScreen(props: { onConnect: (connection: Connection) => vo if (endpoint.trim() && token.trim()) return submit(); }} /> + {insecure && ( + + This HTTP connection is not encrypted. Your bearer token and conversations can be + read by others on the network. Continue only on a trusted local network. + + )} {error && {error}} )} diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index f94cca62874..c4bcb151b4c 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -23,6 +23,7 @@ import type { MuxMessage } from "../../../../src/common/types/message"; import { IconButton, Loading, Notice } from "../components/Controls"; import { Message } from "../components/Message"; import { useConversation } from "../useConversation"; +import { linkedAbortController } from "../useConnection"; import { resolveSettings } from "../settings"; import type { ChatSettings } from "../settings"; import { ModelSettings } from "./ModelSettings"; @@ -31,10 +32,17 @@ import { colors, layout } from "../theme"; export function ConversationScreen(props: { client: MobileClient; workspace: FrontendWorkspaceMetadata; + signal: AbortSignal; + connected: boolean; + onReconnect: () => Promise; onMenu?: () => void; onChanges: () => void; }) { - const { transcript, settings, error, retry } = useConversation(props.client, props.workspace.id); + const { transcript, settings, error } = useConversation( + props.client, + props.workspace.id, + props.signal + ); const [overrides, setOverrides] = useState(null); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); @@ -47,15 +55,18 @@ export function ConversationScreen(props: { // This component is keyed by workspace ID: both subscription and in-flight actions // belong to one workspace, and a switch cannot expose the previous draft/history. useEffect(() => { - const abort = new AbortController(); + const abort = linkedAbortController(props.signal); controller.current = abort; + pending.current = false; + setBusy(false); return () => abort.abort(); - }, []); + }, [props.signal]); const agentId = props.workspace.agentId ?? "exec"; const options = overrides ?? (settings ? resolveSettings(props.workspace, settings, agentId) : null); - const ready = transcript.caughtUp && !error && settings !== null; - const running = transcript.streaming; + const ready = + props.connected && !props.signal.aborted && transcript.caughtUp && !error && settings !== null; + const running = ready && transcript.streaming; async function send() { if (!ready || !options?.model || !draft.trim() || pending.current || running) return; @@ -82,8 +93,10 @@ export function ConversationScreen(props: { `${cause instanceof Error ? cause.message : "Message could not be sent."} If the connection was lost, reload history before retrying to avoid sending twice.` ); } finally { - pending.current = false; - if (!signal.aborted) setBusy(false); + if (controller.current.signal === signal) { + pending.current = false; + if (!signal.aborted) setBusy(false); + } } } @@ -103,8 +116,10 @@ export function ConversationScreen(props: { if (!signal.aborted) setActionError(cause instanceof Error ? cause.message : "Could not interrupt the agent."); } finally { - pending.current = false; - if (!signal.aborted) setBusy(false); + if (controller.current.signal === signal) { + pending.current = false; + if (!signal.aborted) setBusy(false); + } } } @@ -171,7 +186,7 @@ export function ConversationScreen(props: { } ListFooterComponent={ - {error && {error}} + {error && {error}} {transcript.error && {transcript.error}} {running && ( Agent is working… @@ -193,7 +208,7 @@ export function ConversationScreen(props: { { setActionError(null); - retry(); + return props.onReconnect(); }} > {actionError} diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx index 02f79e2cd4f..df0dfcdad10 100644 --- a/packages/mobile/src/screens/CreateWorkspace.tsx +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -6,9 +6,13 @@ import type { Projects } from "../useProjects"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; import { Button, Field, Loading, Notice, Sheet } from "../components/Controls"; import { colors, layout } from "../theme"; +import { linkedAbortController } from "../useConnection"; export function CreateWorkspace(props: { client: MobileClient; + signal: AbortSignal; + connected: boolean; + onReconnect: () => Promise; projects: Projects; onCreated: (workspace: FrontendWorkspaceMetadata) => void; onClose: () => void; @@ -60,6 +64,9 @@ export function CreateWorkspace(props: { @@ -69,6 +76,9 @@ export function CreateWorkspace(props: { function CreateForm(props: { client: MobileClient; + signal: AbortSignal; + connected: boolean; + onReconnect: () => Promise; project: string | null; onCreated: (workspace: FrontendWorkspaceMetadata) => void; }) { @@ -79,12 +89,17 @@ function CreateForm(props: { const [loading, setLoading] = useState(props.project !== null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [generation, setGeneration] = useState(0); const pending = useRef(false); const controller = useRef(new AbortController()); useEffect(() => { - const abort = new AbortController(); + const abort = linkedAbortController(props.signal); controller.current = abort; + pending.current = false; + setBusy(false); + if (abort.signal.aborted) { + setLoading(false); + return; + } if (props.project) { setLoading(true); props.client.projects @@ -104,10 +119,10 @@ function CreateForm(props: { }); } return () => abort.abort(); - }, [props.client, props.project, generation]); + }, [props.client, props.project, props.signal]); async function create() { - if (pending.current) return; + if (pending.current || !props.connected || props.signal.aborted) return; pending.current = true; setBusy(true); setError(null); @@ -139,8 +154,10 @@ function CreateForm(props: { : "Could not create workspace. Refresh the list before retrying if the connection dropped." ); } finally { - pending.current = false; - if (!signal.aborted) setBusy(false); + if (controller.current.signal === signal) { + pending.current = false; + if (!signal.aborted) setBusy(false); + } } } return ( @@ -191,10 +208,15 @@ function CreateForm(props: { )} {loading && } - {error && setGeneration((value) => value + 1)}>{error}} + {error && {error}} + + ) : null + } onScroll={(event) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; setAtBottom(contentSize.height - layoutMeasurement.height - contentOffset.y < 80); diff --git a/packages/mobile/src/settings.test.ts b/packages/mobile/src/settings.test.ts new file mode 100644 index 00000000000..e15be2f9626 --- /dev/null +++ b/packages/mobile/src/settings.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { KNOWN_MODELS } from "../../../src/common/constants/knownModels"; +import { modelChoices, resolveSettings, type SettingsData } from "./settings"; + +function data(): SettingsData { + return { + config: { agentAiDefaults: {} }, + providers: { + anthropic: { isConfigured: true, isEnabled: true, apiKeySet: true }, + openai: { isConfigured: true, isEnabled: false, apiKeySet: true }, + google: { isConfigured: false, isEnabled: true, apiKeySet: false }, + }, + agents: [], + }; +} + +describe("mobile model settings", () => { + test("exposes built-ins for configured providers even without a custom catalog", () => { + const options = modelChoices(data(), ""); + expect(options.length).toBeGreaterThan(0); + expect(options.every((id) => id.startsWith("anthropic:"))).toBe(true); + }); + test("honors hidden models while retaining the active choice and custom models", () => { + const config = data(); + const hidden = KNOWN_MODELS.SONNET.id; + config.config.hiddenModels = [hidden]; + config.providers.anthropic.models = ["custom-model"]; + expect(modelChoices(config, "")).not.toContain(hidden); + const options = modelChoices(config, hidden); + expect(options).toContain(hidden); + expect(options.filter((id) => id === hidden)).toHaveLength(1); + expect(options).toContain("anthropic:custom-model"); + }); + test("resolves agent-scoped workspace settings ahead of global preferences", () => { + const config = data(); + config.config.defaultModel = "fallback:model"; + config.config.agentAiDefaults = { exec: { modelString: "global:exec", thinkingLevel: "low" } }; + expect( + resolveSettings( + { agentId: "plan", aiSettings: { model: "legacy:plan", thinkingLevel: "medium" } }, + config, + "exec" + ).model + ).toBe("global:exec"); + expect( + resolveSettings( + { aiSettingsByAgent: { exec: { model: "workspace:exec", thinkingLevel: "high" } } }, + config, + "exec" + ) + ).toEqual({ + agentId: "exec", + model: "workspace:exec", + thinkingLevel: "high", + reasoningMode: undefined, + }); + }); +}); diff --git a/packages/mobile/src/settings.ts b/packages/mobile/src/settings.ts index e57c09de952..3eb24436b80 100644 --- a/packages/mobile/src/settings.ts +++ b/packages/mobile/src/settings.ts @@ -1,10 +1,14 @@ +import { DEFAULT_MODEL, KNOWN_MODELS } from "../../../src/common/constants/knownModels"; import type { MobileClient } from "./api"; import type { FrontendWorkspaceMetadata } from "../../../src/common/types/workspace"; import type { SendMessageOptions } from "../../../src/common/orpc/types"; import type { ThinkingLevel } from "../../../src/common/types/thinking"; export type SettingsData = { - config: Awaited>; + config: Pick< + Awaited>, + "agentAiDefaults" | "defaultModel" | "hiddenModels" + >; providers: Awaited>; agents: Awaited>; }; @@ -15,7 +19,7 @@ export type ChatSettings = Pick< export const thinkingLevels: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh", "max"]; export function resolveSettings( - workspace: FrontendWorkspaceMetadata, + workspace: Pick, data: SettingsData, agentId: string ): ChatSettings { @@ -31,7 +35,7 @@ export function resolveSettings( globalDefaults?.modelString ?? agentDefaults?.model ?? data.config.defaultModel ?? - "", + DEFAULT_MODEL, thinkingLevel: workspaceDefaults?.thinkingLevel ?? globalDefaults?.thinkingLevel ?? @@ -44,6 +48,11 @@ export function modelChoices(data: SettingsData, currentModel: string): string[] const models = new Set(); if (currentModel) models.add(currentModel); if (data.config.defaultModel) models.add(data.config.defaultModel); + // getConfig lists custom/discovered models, not the built-in desktop catalog. + for (const model of Object.values(KNOWN_MODELS)) { + const provider = data.providers[model.provider]; + if (provider?.isConfigured && provider.isEnabled) models.add(model.id); + } for (const [provider, config] of Object.entries(data.providers)) { if (!config.isEnabled || !config.isConfigured) continue; for (const entry of [...(config.models ?? []), ...(config.discoveredModels ?? [])]) { diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts new file mode 100644 index 00000000000..633b304bd28 --- /dev/null +++ b/packages/mobile/src/useConversation.test.ts @@ -0,0 +1,125 @@ +import "./testDom"; +import { afterEach, expect, test } from "bun:test"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createORPCClient } from "@orpc/client"; +import type { MobileClient } from "./api"; +import type { WorkspaceChatMessage } from "./transcript"; +import { useConversation } from "./useConversation"; + +afterEach(cleanup); + +function message(sequence: number, text = `message ${sequence}`): WorkspaceChatMessage { + return { + type: "message", + id: String(sequence), + role: "assistant", + parts: [{ type: "text", text }], + metadata: { historySequence: sequence }, + }; +} + +function fixture() { + type Page = Awaited>; + let complete!: (page: Page) => void; + const page = new Promise((resolve) => { + complete = resolve; + }); + let eventController!: ReadableStreamDefaultController; + const events = new ReadableStream({ + start(controller) { + eventController = controller; + }, + }); + const requests: Array<{ input: unknown; signal?: AbortSignal }> = []; + const client = createORPCClient({ + call: async (path, input, options) => { + switch (path.join(".")) { + case "config.getConfig": + return { agentAiDefaults: {} }; + case "providers.getConfig": + return {}; + case "agents.list": + return []; + case "workspace.onChat": + options.signal?.addEventListener("abort", () => eventController.close(), { once: true }); + return events.values(); + case "workspace.history.loadMore": + requests.push({ input, signal: options.signal }); + return page; + default: + throw new Error(`Unexpected call: ${path.join(".")}`); + } + }, + }); + const lifetime = new AbortController(); + const view = renderHook(() => useConversation(client, "workspace", lifetime.signal)); + return { + ...view, + complete, + requests, + async ready() { + await act(async () => { + eventController.enqueue(message(10, "current")); + eventController.enqueue({ type: "caught-up", hasOlderHistory: true }); + }); + await waitFor(() => expect(view.result.current.transcript.caughtUp).toBe(true)); + }, + async emit(event: WorkspaceChatMessage) { + await act(async () => eventController.enqueue(event)); + }, + }; +} + +test("older history is inserted without replacing newer copies and uses the oldest visible row", async () => { + const view = fixture(); + await view.ready(); + await act(async () => { + const pending = view.result.current.loadOlder(); + view.complete({ + messages: [message(2), message(10, "stale")], + nextCursor: null, + hasOlder: false, + }); + await pending; + }); + expect(view.requests[0].input).toEqual({ + workspaceId: "workspace", + cursor: { beforeHistorySequence: 10, beforeMessageId: "10" }, + }); + expect(view.result.current.transcript.messages.map((item) => item.id)).toEqual(["2", "10"]); + expect(view.result.current.transcript.messages[1].parts).toEqual([ + { type: "text", text: "current" }, + ]); + expect(view.result.current.transcript.hasOlderHistory).toBe(false); +}); + +test("truncate cancels an older-history read so its late response cannot resurrect removed messages", async () => { + const view = fixture(); + await view.ready(); + let pending!: Promise; + act(() => { + pending = view.result.current.loadOlder(); + }); + await view.emit({ type: "delete", historySequences: [2, 10] }); + expect(view.requests[0].signal?.aborted).toBe(true); + await act(async () => { + view.complete({ messages: [message(2)], nextCursor: null, hasOlder: false }); + await pending; + }); + expect(view.result.current.transcript.messages).toEqual([]); +}); + +test("switching away aborts an in-flight page and duplicate taps do not start another read", async () => { + const view = fixture(); + await view.ready(); + let pending!: Promise; + act(() => { + pending = view.result.current.loadOlder(); + view.result.current.loadOlder(); + }); + expect(view.requests).toHaveLength(1); + view.unmount(); + expect(view.requests[0].signal?.aborted).toBe(true); + view.complete({ messages: [message(2)], nextCursor: null, hasOlder: false }); + await pending; +}); diff --git a/packages/mobile/src/useConversation.ts b/packages/mobile/src/useConversation.ts index 6ded6e45907..660f0db3a8f 100644 --- a/packages/mobile/src/useConversation.ts +++ b/packages/mobile/src/useConversation.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { MobileClient } from "./api"; import { applyChatEvent, createTranscriptState } from "./transcript"; import type { SettingsData } from "./settings"; @@ -9,12 +9,22 @@ export function useConversation(client: MobileClient, workspaceId: string, signa const [settings, setSettings] = useState(null); const [error, setError] = useState(null); const [owner, setOwner] = useState(() => client); + const [loadingOlder, setLoadingOlder] = useState(false); + const [historyError, setHistoryError] = useState(null); + const historyRequest = useRef(null); + type HistoryCursor = NonNullable< + Parameters[0]["cursor"] + >; + const historyCursor = useRef(null); useEffect(() => { const controller = linkedAbortController(signal); setTranscript(createTranscriptState()); setSettings(null); setError(null); setOwner(() => client); + setLoadingOlder(false); + setHistoryError(null); + historyCursor.current = null; if (signal.aborted) return; async function subscribe() { const [config, providers, agents] = await Promise.all([ @@ -30,6 +40,13 @@ export function useConversation(client: MobileClient, workspaceId: string, signa ); for await (const event of events) { if (controller.signal.aborted) return; + if (event.type === "delete") { + // A page read before a truncate must not resurrect deleted history. + historyRequest.current?.abort(); + historyRequest.current = null; + historyCursor.current = null; + setLoadingOlder(false); + } setTranscript((current) => applyChatEvent(current, event)); } if (!controller.signal.aborted) @@ -45,11 +62,75 @@ export function useConversation(client: MobileClient, workspaceId: string, signa : "Could not load the conversation. Retry to reconnect." ); }); - return () => controller.abort(); + return () => { + controller.abort(); + historyRequest.current?.abort(); + historyRequest.current = null; + }; }, [client, workspaceId, signal]); + async function loadOlder() { + if ( + owner !== client || + signal.aborted || + !transcript.caughtUp || + !transcript.hasOlderHistory || + historyRequest.current + ) + return; + const controller = linkedAbortController(signal); + historyRequest.current = controller; + setLoadingOlder(true); + setHistoryError(null); + const oldest = transcript.messages.find((message) => message.metadata?.historySequence != null); + try { + const page = await client.workspace.history.loadMore( + { + workspaceId, + cursor: + historyCursor.current ?? + (oldest + ? { + beforeHistorySequence: oldest.metadata!.historySequence!, + beforeMessageId: oldest.id, + } + : undefined), + }, + { signal: controller.signal } + ); + if (controller.signal.aborted) return; + historyCursor.current = page.nextCursor; + setTranscript((current) => { + // Pages are historical snapshots; never replay old stream lifecycle events + // over the current live turn or replace a newer copy of an existing row. + let next = current; + for (const event of page.messages) { + if ( + event.type === "message" && + !next.messages.some((message) => message.id === event.id) + ) { + next = applyChatEvent(next, event); + } + } + return { ...next, hasOlderHistory: page.hasOlder }; + }); + } catch { + if (!controller.signal.aborted) setHistoryError("Could not load older messages. Try again."); + } finally { + if (historyRequest.current === controller) { + historyRequest.current = null; + setLoadingOlder(false); + } + controller.abort(); + } + } // A replacement client must never inherit the old socket’s caught-up flag, even // for the render before the subscription effect runs. Draft state lives above this hook. - return owner === client - ? { transcript, settings, error } - : { transcript: createTranscriptState(), settings: null, error: null }; + return { + transcript: owner === client ? transcript : createTranscriptState(), + settings: owner === client ? settings : null, + error: owner === client ? error : null, + loadingOlder, + historyError, + loadOlder, + }; } diff --git a/packages/mobile/src/useProjects.ts b/packages/mobile/src/useProjects.ts index 223f359d8e9..f8aed0bbc8e 100644 --- a/packages/mobile/src/useProjects.ts +++ b/packages/mobile/src/useProjects.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import type { MobileClient } from "./api"; import type { FrontendWorkspaceMetadata } from "../../../src/common/types/workspace"; import { isWorkspaceArchived } from "../../../src/common/utils/archive"; +import { SCRATCH_PROJECT_CONFIG_KEY } from "../../../src/common/constants/scratch"; import { linkedAbortController } from "./useConnection"; export type Projects = Awaited>; @@ -27,7 +28,8 @@ export function useProjects(client: MobileClient, signal: AbortSignal) { client.workspace.list(undefined, { signal: controller.signal }), ]); if (controller.signal.aborted) return; - setProjects(projectList); + // Scratch chats have their own creation path, not a git worktree target. + setProjects(projectList.filter(([path]) => path !== SCRATCH_PROJECT_CONFIG_KEY)); setWorkspaces(workspaceList); setLoading(false); for await (const event of events) { diff --git a/src/node/builtinSkills/xum-docs.md b/src/node/builtinSkills/xum-docs.md index 2d60ddd32b5..7fbcd5efc8a 100644 --- a/src/node/builtinSkills/xum-docs.md +++ b/src/node/builtinSkills/xum-docs.md @@ -101,6 +101,7 @@ Use this index to find a page's: - **Integrations** - VS Code Extension (`/integrations/vscode-extension`) → `references/docs/integrations/vscode-extension.mdx`: Pair Xum workspaces with VS Code and Cursor editors - ACP (Editor Integrations) (`/integrations/acp`) → `references/docs/integrations/acp.mdx`: Connect Xum to Zed, Neovim, and JetBrains via the Agent Client Protocol + - Mobile companion (`/integrations/mobile-app`) → `references/docs/integrations/mobile-app.md`: Develop the React Native Xum companion and connect it to your server. - **Reference** - Mux compatibility (`/reference/mux-compatibility`) → `references/docs/reference/mux-compatibility.mdx`: Upgrade, downgrade, storage, command, environment, and deep-link compatibility during the Xum rename - Debugging (`/reference/debugging`) → `references/docs/reference/debugging.mdx`: View live backend logs and diagnose issues diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 47307818efe..692afce01f7 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -5227,7 +5227,11 @@ export const BUILTIN_SKILL_FILES: Record> = { " },", " {", ' "group": "Integrations",', - ' "pages": ["integrations/vscode-extension", "integrations/acp"]', + ' "pages": [', + ' "integrations/vscode-extension",', + ' "integrations/acp",', + ' "integrations/mobile-app"', + " ]", " },", " {", ' "group": "Reference",', @@ -6974,6 +6978,115 @@ export const BUILTIN_SKILL_FILES: Record> = { "- [Workspaces](/workspaces)", "", ].join("\n"), + "references/docs/integrations/mobile-app.md": [ + "---", + "title: Mobile companion", + "description: Develop the React Native Xum companion and connect it to your server.", + "---", + "", + "The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website.", + "", + "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. The project/workspace navigator, bottom composer, and full-screen workspace panels follow Xum's mobile navigation. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", + "", + "## Connect to a server", + "", + "Enable [server access](/config/server-access), or start `xum server`. Use a trusted HTTPS endpoint accessible from the device and enter the server's bearer token separately. Include any reverse-proxy path prefix in the endpoint. A Coder login page or another upstream authentication layer may require additional network access; the Xum token does not authenticate to that outer layer.", + "", + "The token grants access to the server, including its code-execution capabilities. Treat it like a password. Native builds save connection details in device secure storage. The web preview keeps them in memory only; refreshing requires entering them again. Disconnect clears the saved native connection.", + "", + "Public endpoints require HTTPS. Literal private LAN and loopback HTTP addresses are accepted for development, with a plaintext-token warning. Mobile platform transport policies may still restrict cleartext networking; prefer HTTPS on devices. A phone's `localhost` refers to the phone, not your development computer.", + "", + "## Develop with React Native Web", + "", + "Install the repository's Bun dependencies and use Node.js 22.19 or later for Expo and the preview proxy:", + "", + "```bash", + "bun install", + "make mobile-install", + "", + "# Point this at a running Xum instance. Do not put the token in the URL.", + "XUM_MOBILE_ENDPOINT=http://127.0.0.1:3000 make mobile-web", + "```", + "", + "Open `http://127.0.0.1:8082`, then enter that configured **server endpoint** and its token. Metro runs on port 8081; use the proxy on 8082, not Metro's direct URL, for API access.", + "", + "The preview forwards to exactly one endpoint configured at startup. It checks the request Host and Origin before forwarding, strips preview cookies/forwarded identity, and preserves the upstream path prefix. It does not relax the production server's origin protections. Native builds connect directly and do not need this proxy.", + "", + "Optional development settings:", + "", + "- `MOBILE_METRO_PORT`: Metro port (Make variable).", + "- `XUM_MOBILE_PORT`: preview port, default 8082.", + "- `XUM_MOBILE_ORIGIN`: exact public preview origin when forwarding this loopback-bound server; the forwarding proxy must preserve that Host.", + "- `XUM_MOBILE_ENDPOINT`: the fixed Xum target; restart the preview to change it.", + "", + "Serve a production web export:", + "", + "```bash", + "make mobile-export", + "XUM_MOBILE_ENDPOINT=http://127.0.0.1:3000 make mobile-preview", + "```", + "", + "The preview is development tooling, not a general-purpose public proxy. It intentionally runs under Node: Bun's Node HTTP compatibility can stall forwarded WebSocket frames.", + "", + "## Native development", + "", + "```bash", + "make mobile-native", + "```", + "", + "Use Expo's device/simulator workflow with the installed SDK-compatible client or development build. The native app uses `expo-secure-store` and safe-area/keyboard-aware layouts. Native networking, keyboard behavior, secure storage, and background/resume behavior still need device testing; a successful JavaScript export does not establish that they work on iOS.", + "", + "```bash", + "# Compiles the iOS JavaScript/Hermes bundle; does not launch or build a simulator app.", + "make mobile-export-ios", + "```", + "", + "The mobile dependency graph and lockfile are isolated from desktop React. Update SDK-compatible versions together and run `bun x expo install --check` from `packages/mobile` after dependency changes.", + "", + "## Validation and dogfooding", + "", + "```bash", + "make mobile-check", + "make mobile-export", + "make mobile-export-ios", + "```", + "", + "`mobile-check` runs typechecking against the shared API schemas, lint/format checks, and endpoint, transport, transcript, lifecycle, and preview-proxy tests. For the opt-in real-server test, use a **disposable** Xum root with `XUM_MOCK_AI=1`, then run:", + "", + "```bash", + "cd packages/mobile", + "XUM_MOBILE_TEST_ENDPOINT=http://127.0.0.1:3000 \\", + "XUM_MOBILE_TEST_TOKEN=your-disposable-server-token \\", + "bun test ./scripts/server.integration.test.ts", + "```", + "", + "That test creates and removes a scratch workspace. It exercises real authentication, persistence, streaming and reconnect/replay; only the model response is deterministic.", + "", + "For a browser walkthrough, use the production preview and a phone viewport around 375–390 pixels, then repeat at tablet/desktop width:", + "", + "1. Check invalid URL, wrong token, and successful connection.", + "2. Open the workspace navigator; create a scratch chat and a project workspace.", + "3. Send a message, observe streamed text/tools/reasoning, and interrupt a running turn.", + "4. Change agent/model settings, switch workspaces, and verify conversations do not mix.", + "5. Open Changes and Settings; disconnect and confirm credentials are not retained in browser storage.", + "6. Drop the connection, retry, and verify authoritative history reloads before sending is enabled.", + "7. Capture screenshots and a short recording of the walkthrough, including narrow layouts and any failure/recovery steps.", + "", + "## Can Xum run inside the native JS engine?", + "", + "**Not the existing backend unchanged.** Hermes executes JavaScript, but it does not provide Xum's Node filesystem/process APIs, shell/git toolchain, PTY bindings, or database/native addons. The companion runs its UI and client logic locally; agent execution stays on the server.", + "", + "A native Node sidecar would be a separate runtime and substantial platform port. It would not make the desktop shell tools available inside an iOS sandbox. This app does not advertise an embedded backend mode.", + "", + "Research references:", + "", + "- [React Native core/native views](https://reactnative.dev/docs/intro-react-native-components)", + "- [Hermes](https://reactnative.dev/docs/hermes)", + "- [Expo web support](https://docs.expo.dev/workflow/web/)", + "- [SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/)", + "- [Node.js Mobile's separate native runtime](https://nodejs-mobile.github.io/docs/guide/guide-react-native/getting-started/)", + "", + ].join("\n"), "references/docs/integrations/vscode-extension.mdx": [ "---", "title: VS Code Extension", @@ -8830,6 +8943,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " - **Integrations**", " - VS Code Extension (`/integrations/vscode-extension`) → `references/docs/integrations/vscode-extension.mdx`: Pair Xum workspaces with VS Code and Cursor editors", " - ACP (Editor Integrations) (`/integrations/acp`) → `references/docs/integrations/acp.mdx`: Connect Xum to Zed, Neovim, and JetBrains via the Agent Client Protocol", + " - Mobile companion (`/integrations/mobile-app`) → `references/docs/integrations/mobile-app.md`: Develop the React Native Xum companion and connect it to your server.", " - **Reference**", " - Mux compatibility (`/reference/mux-compatibility`) → `references/docs/reference/mux-compatibility.mdx`: Upgrade, downgrade, storage, command, environment, and deep-link compatibility during the Xum rename", " - Debugging (`/reference/debugging`) → `references/docs/reference/debugging.mdx`: View live backend logs and diagnose issues", From f53be0fdf9e6cd0cde6827c68f10d1aa7cc7aa08 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 16:44:00 +0000 Subject: [PATCH 06/84] =?UTF-8?q?=F0=9F=A4=96=20feat:=20polish=20native=20?= =?UTF-8?q?mobile=20forms=20and=20sheets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify native spacing, type, control/card/sheet radii, and grouped form styling. Keep sheet actions visible, bound web sheets with a dismissible scrim, protect pending workspace creation from dismissal, and require confirmation before disconnecting. Add friendly model search, token visibility, and native keyboard focus progression. Validation: mobile TypeScript, targeted ESLint, formatting, 60 mobile source tests including five isolated RN-Web form behavior cases. Parent owns integrated desktop/mobile screenshot and recording gates. --- packages/mobile/src/components/Controls.tsx | 251 ++++++++--- packages/mobile/src/screens/ConnectScreen.tsx | 126 +++--- .../mobile/src/screens/CreateWorkspace.tsx | 397 +++++++++++------- packages/mobile/src/screens/ModelSettings.tsx | 295 +++++++++---- .../mobile/src/screens/SettingsScreen.tsx | 143 +++++-- packages/mobile/src/screens/formTestDom.ts | 10 + .../mobile/src/screens/formTestPlatform.ts | 31 ++ .../mobile/src/screens/forms.behavior.tsx | 180 ++++++++ packages/mobile/src/screens/forms.test.ts | 33 ++ packages/mobile/src/theme.ts | 35 +- 10 files changed, 1093 insertions(+), 408 deletions(-) create mode 100644 packages/mobile/src/screens/formTestDom.ts create mode 100644 packages/mobile/src/screens/formTestPlatform.ts create mode 100644 packages/mobile/src/screens/forms.behavior.tsx create mode 100644 packages/mobile/src/screens/forms.test.ts diff --git a/packages/mobile/src/components/Controls.tsx b/packages/mobile/src/components/Controls.tsx index a72a39cc2c9..73dab470977 100644 --- a/packages/mobile/src/components/Controls.tsx +++ b/packages/mobile/src/components/Controls.tsx @@ -1,7 +1,10 @@ +import { forwardRef } from "react"; import type { ReactNode } from "react"; import { ActivityIndicator, + KeyboardAvoidingView, Modal, + Platform, Pressable, ScrollView, StyleSheet, @@ -11,9 +14,9 @@ import { } from "react-native"; import type { TextInputProps } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { AlertCircle, ArrowLeft, X } from "lucide-react-native"; +import { AlertCircle, ChevronLeft, Info, TriangleAlert, X } from "lucide-react-native"; import type { LucideIcon } from "lucide-react-native"; -import { colors, layout } from "../theme"; +import { colors, layout, radii, spacing, typography } from "../theme"; export function IconButton(props: { label: string; @@ -27,14 +30,12 @@ export function IconButton(props: { [ - styles.iconButton, - { opacity: props.disabled ? 0.35 : pressed ? 0.6 : 1 }, - ]} + style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]} > - + ); } @@ -45,55 +46,88 @@ export function Button(props: { disabled?: boolean; busy?: boolean; secondary?: boolean; + destructive?: boolean; icon?: LucideIcon; }) { const Icon = props.icon; + const disabled = props.disabled || props.busy; + const foreground = props.disabled + ? colors.muted + : props.destructive + ? colors.danger + : props.secondary + ? colors.bright + : colors.background; return ( [ styles.button, props.secondary && styles.secondary, - { opacity: props.disabled || props.busy ? 0.5 : pressed ? 0.7 : 1 }, + props.destructive && styles.destructive, + props.disabled && styles.secondary, + pressed && styles.pressed, ]} > {props.busy ? ( - + ) : Icon ? ( - + ) : null} - {props.children} + {props.children} ); } -export function Field(props: TextInputProps & { label: string }) { - const { label, ...inputProps } = props; +export const Field = forwardRef< + TextInput, + TextInputProps & { label: string; trailing?: ReactNode } +>(function Field(props, ref) { + const { label, trailing, ...inputProps } = props; return ( - + {label} - + + + {trailing} + ); -} +}); -export function Notice(props: { children: string; onRetry?: () => void }) { +export function Notice(props: { + children: string; + onRetry?: () => void; + severity?: "error" | "warning" | "info"; +}) { + const severity = props.severity ?? "error"; + const Icon = severity === "warning" ? TriangleAlert : severity === "info" ? Info : AlertCircle; + const tint = + severity === "warning" ? colors.warning : severity === "info" ? colors.muted : colors.danger; return ( - + - - + + {props.children} @@ -123,13 +157,13 @@ export function Header(props: { }) { return ( - {props.onBack && } + {props.onBack && } {props.title} {props.subtitle && ( - + {props.subtitle} )} @@ -139,18 +173,69 @@ export function Header(props: { ); } -export function Sheet(props: { title: string; children: ReactNode; onClose: () => void }) { +export function Sheet(props: { + title: string; + children: ReactNode; + onClose: () => void; + footer?: ReactNode; + dismissDisabled?: boolean; +}) { + function dismiss() { + if (!props.dismissDisabled) props.onClose(); + } + const web = Platform.OS === "web"; return ( - - -
} - /> - - {props.children} - - + + + {web && ( + + )} + + +
+ } + /> + + {props.children} + + {props.footer && {props.footer}} + + + ); } @@ -161,49 +246,77 @@ const styles = StyleSheet.create({ minHeight: 44, alignItems: "center", justifyContent: "center", - borderRadius: 8, + borderRadius: radii.control, }, + pressed: { opacity: 0.7 }, button: { - minHeight: 48, - borderRadius: 10, - paddingHorizontal: 16, - paddingVertical: 12, + minHeight: 50, + borderRadius: radii.control, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: 8, + gap: spacing.sm, backgroundColor: colors.accent, }, secondary: { backgroundColor: colors.elevated }, - buttonText: { color: colors.bright, fontSize: 15, fontWeight: "600" }, + destructive: { backgroundColor: colors.dangerSurface }, + buttonText: { ...typography.header, textAlign: "center" }, + field: { + flexDirection: "row", + alignItems: "center", + backgroundColor: colors.elevated, + borderRadius: radii.control, + paddingRight: spacing.xs, + }, input: { + ...typography.body, color: colors.bright, - backgroundColor: colors.panel, - borderColor: colors.border, - borderWidth: 1, - borderRadius: 10, - minHeight: 48, - paddingHorizontal: 14, - paddingVertical: 12, - fontSize: 15, + minWidth: 0, + flex: 1, + minHeight: 50, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, }, notice: { - padding: 14, - gap: 12, + padding: spacing.lg, + gap: spacing.md, backgroundColor: colors.panel, - borderWidth: 1, - borderColor: colors.border, - borderRadius: 10, + borderRadius: radii.control, + }, + loading: { + padding: spacing.xxl, + gap: spacing.md, + alignItems: "center", + justifyContent: "center", }, - loading: { padding: 28, gap: 12, alignItems: "center", justifyContent: "center" }, header: { flexDirection: "row", - gap: 8, + gap: spacing.sm, alignItems: "center", - paddingHorizontal: 12, - minHeight: 68, + paddingHorizontal: spacing.lg, + minHeight: 56, borderBottomColor: colors.border, - borderBottomWidth: 1, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + headerTitle: { ...typography.header, color: colors.bright }, + webOverlay: { flex: 1, justifyContent: "flex-end", alignItems: "center" }, + scrim: { ...StyleSheet.absoluteFill, backgroundColor: colors.scrim }, + webSheet: { + backgroundColor: colors.background, + width: "100%", + maxWidth: 600, + maxHeight: "90%", + borderTopLeftRadius: radii.sheet, + borderTopRightRadius: radii.sheet, + overflow: "hidden", + }, + sheetContent: { flexGrow: 1, flexShrink: 1 }, + footer: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, + padding: spacing.xl, + gap: spacing.md, }, - headerTitle: { color: colors.bright, fontWeight: "600", fontSize: 16 }, }); diff --git a/packages/mobile/src/screens/ConnectScreen.tsx b/packages/mobile/src/screens/ConnectScreen.tsx index 0b0390eb897..bd9f6e1849b 100644 --- a/packages/mobile/src/screens/ConnectScreen.tsx +++ b/packages/mobile/src/screens/ConnectScreen.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from "react-native"; -import { ArrowRight, ShieldCheck } from "lucide-react-native"; +import { ArrowRight, Eye, EyeOff, ShieldCheck } from "lucide-react-native"; +import type { TextInput } from "react-native"; import { connect } from "../connection"; import { isInsecureEndpoint } from "../endpoint"; import { loadCredentials, saveCredentials } from "../credentials"; -import { Button, Field, Loading, Notice } from "../components/Controls"; -import { colors, layout } from "../theme"; +import { Button, Field, IconButton, Loading, Notice } from "../components/Controls"; +import { colors, layout, spacing, typography } from "../theme"; export type Connection = Awaited>; @@ -16,6 +17,8 @@ export function ConnectScreen(props: { onConnect: (connection: Connection) => vo const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const request = useRef(null); + const tokenInput = useRef(null); + const [showToken, setShowToken] = useState(false); let insecure = false; try { insecure = isInsecureEndpoint(endpoint.trim()); @@ -85,49 +88,66 @@ export function ConnectScreen(props: { onConnect: (connection: Connection) => vo return ( - + - - xum. - - - {"Your agents.\nWithin reach."} - - Connect to your Xum server to pick up a conversation, review changes, or start - something new. + + + xum. + Connect to Xum + Enter the address of your Xum server. {loading ? ( ) : ( <> - - { - if (endpoint.trim() && token.trim()) return submit(); - }} - /> + + tokenInput.current?.focus()} + /> + { + // HTTP requires the explicitly labelled button, not a keyboard shortcut. + if (!insecure && endpoint.trim() && token.trim()) return submit(); + }} + trailing={ + setShowToken(!showToken)} + disabled={busy} + /> + } + /> + {insecure && ( - - This HTTP connection is not encrypted. Your bearer token and conversations can be - read by others on the network. Continue only on a trusted local network. + + HTTP is not encrypted. Your token and conversations can be read by others on the + network. Connect only on a trusted local network. )} {error && {error}} @@ -135,21 +155,18 @@ export function ConnectScreen(props: { onConnect: (connection: Connection) => vo icon={ArrowRight} busy={busy} disabled={!endpoint.trim() || !token.trim()} - onPress={() => { - return submit(); - }} + onPress={submit} > - {insecure ? "Connect without encryption" : "Connect to Xum"} + {insecure ? "Connect without encryption" : "Connect"} )} - - + + {Platform.OS === "web" - ? "Your token stays in this tab’s memory. It is never saved in browser storage." - : "Your connection is saved in this device’s secure credential storage."}{" "} - Use a trusted HTTPS server; unencrypted connections are only for local development. + ? "Your token stays in this tab. It is never saved in browser storage." + : "Your connection is saved securely on this device."} @@ -159,14 +176,15 @@ export function ConnectScreen(props: { onConnect: (connection: Connection) => vo } const styles = StyleSheet.create({ - page: { flexGrow: 1, justifyContent: "center", padding: 28 }, - form: { gap: 24, maxWidth: 420, width: "100%", alignSelf: "center" }, - wordmark: { fontSize: 38, fontWeight: "700", letterSpacing: -2, color: colors.bright }, - title: { + page: { flexGrow: 1, padding: spacing.xl, paddingTop: spacing.xxxl }, + form: { gap: spacing.xxl, maxWidth: 480, width: "100%", alignSelf: "center" }, + wordmark: { color: colors.bright, - fontSize: 30, - lineHeight: 38, - fontWeight: "600", - letterSpacing: -0.8, + fontSize: 28, + lineHeight: 36, + fontWeight: "700", + letterSpacing: -1, + marginBottom: spacing.lg, }, + fields: { padding: spacing.lg, gap: spacing.xl }, }); diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx index df0dfcdad10..b2593be39e2 100644 --- a/packages/mobile/src/screens/CreateWorkspace.tsx +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState } from "react"; -import { Pressable, Text, View } from "react-native"; -import { Folder, MessageSquare } from "lucide-react-native"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import type { TextInput } from "react-native"; +import { Check, ChevronDown, Folder, MessageSquare } from "lucide-react-native"; import type { MobileClient } from "../api"; import type { Projects } from "../useProjects"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; import { Button, Field, Loading, Notice, Sheet } from "../components/Controls"; -import { colors, layout } from "../theme"; +import { colors, layout, radii, spacing, typography } from "../theme"; import { linkedAbortController } from "../useConnection"; export function CreateWorkspace(props: { @@ -18,124 +19,79 @@ export function CreateWorkspace(props: { onClose: () => void; }) { const [project, setProject] = useState(null); - return ( - - - Start a scratch conversation or an isolated worktree in an existing server project. - - setProject(null)} - style={[ - layout.row, - { - padding: 12, - minHeight: 48, - borderRadius: 8, - backgroundColor: project === null ? colors.elevated : colors.panel, - }, - ]} - > - - Scratch chat - - {props.projects.map(([path, config]) => ( - setProject(path)} - style={[ - layout.row, - { - padding: 12, - minHeight: 48, - borderRadius: 8, - backgroundColor: project === path ? colors.elevated : colors.panel, - }, - ]} - > - - - {config.displayName ?? path.split(/[\\/]/).at(-1)} - - - ))} - - - ); -} - -function CreateForm(props: { - client: MobileClient; - signal: AbortSignal; - connected: boolean; - onReconnect: () => Promise; - project: string | null; - onCreated: (workspace: FrontendWorkspaceMetadata) => void; -}) { + const [choosingProject, setChoosingProject] = useState(false); const [title, setTitle] = useState(""); const [branch, setBranch] = useState(""); const [trunk, setTrunk] = useState(""); const [branches, setBranches] = useState([]); - const [loading, setLoading] = useState(props.project !== null); + const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const pending = useRef(false); const controller = useRef(new AbortController()); + const branchInput = useRef(null); + const trunkInput = useRef(null); + useEffect(() => { const abort = linkedAbortController(props.signal); controller.current = abort; pending.current = false; setBusy(false); - if (abort.signal.aborted) { + if (abort.signal.aborted || !project) { setLoading(false); - return; - } - if (props.project) { - setLoading(true); - props.client.projects - .listBranches({ projectPath: props.project }, { signal: abort.signal }) - .then((result) => { - if (abort.signal.aborted) return; - setBranches(result.branches); - setTrunk(result.recommendedTrunk ?? ""); - setError(null); - }) - .catch((cause: unknown) => { - if (!abort.signal.aborted) - setError(cause instanceof Error ? cause.message : "Could not load branches."); - }) - .finally(() => { - if (!abort.signal.aborted) setLoading(false); - }); + return () => abort.abort(); } + setLoading(true); + props.client.projects + .listBranches({ projectPath: project }, { signal: abort.signal }) + .then((result) => { + if (abort.signal.aborted) return; + setBranches(result.branches); + setTrunk(result.recommendedTrunk ?? ""); + setError(null); + }) + .catch((cause: unknown) => { + if (!abort.signal.aborted) + setError(cause instanceof Error ? cause.message : "Could not load branches."); + }) + .finally(() => { + if (!abort.signal.aborted) setLoading(false); + }); return () => abort.abort(); - }, [props.client, props.project, props.signal]); + }, [props.client, project, props.signal]); + + function selectProject(path: string | null) { + if (pending.current) return; + setProject(path); + setChoosingProject(false); + setBranch(""); + setTrunk(""); + setBranches([]); + setError(null); + } async function create() { - if (pending.current || !props.connected || props.signal.aborted) return; + if ( + pending.current || + !props.connected || + props.signal.aborted || + loading || + (project && !trunk.trim()) + ) + return; pending.current = true; setBusy(true); setError(null); const signal = controller.current.signal; try { - const result = props.project + // Omitting runtimeConfig selects the server-owned worktree directory. + const result = project ? await props.client.workspace.create( { - projectPath: props.project, + projectPath: project, branchName: branch.trim() || undefined, trunkBranch: trunk.trim(), title: title.trim() || undefined, - // Omission selects the server's worktree default and server-owned src directory. }, { signal } ) @@ -149,9 +105,7 @@ function CreateForm(props: { } catch (cause) { if (!signal.aborted) setError( - cause instanceof Error - ? cause.message - : "Could not create workspace. Refresh the list before retrying if the connection dropped." + `${cause instanceof Error ? cause.message : "Could not create workspace."} If the connection dropped, refresh the workspace list before trying again.` ); } finally { if (controller.current.signal === signal) { @@ -160,67 +114,206 @@ function CreateForm(props: { } } } + + const selectedProject = props.projects.find(([path]) => path === project); + const projectName = + selectedProject?.[1].displayName ?? + project?.split(/[\\/]/).filter(Boolean).at(-1) ?? + "Scratch chat"; return ( - - - {props.project && ( + { + if (!pending.current) props.onClose(); + }} + dismissDisabled={busy} + footer={ <> - - - {branches.length > 0 && ( - - {branches.slice(0, 8).map((name) => ( - setTrunk(name)} - style={{ - padding: 12, - minHeight: 44, - borderRadius: 8, - backgroundColor: colors.panel, - }} - > - {name} - - ))} - + + {busy && ( + Creating on your server. Keep this sheet open. )} - )} + } + > + + Project + setChoosingProject(!choosingProject)} + style={[styles.row, layout.group]} + > + {project ? ( + + ) : ( + + )} + + + {projectName} + + + {project ? "Isolated Git worktree" : "Conversation without a project"} + + + + + {choosingProject && ( + + selectProject(null)} + /> + {props.projects.map(([path, config]) => ( + selectProject(path)} + /> + ))} + {props.projects.length === 0 && ( + + Add a project from Xum desktop to create worktrees. + + )} + + )} + + + branchInput.current?.focus()} + /> + {project && ( + <> + + trunkInput.current?.focus()} + /> + + {branches.length > 0 && ( + + {branches.slice(0, 6).map((name) => ( + setTrunk(name)} + style={[styles.branch, trunk === name && styles.selectedBranch]} + > + + {name} + + {trunk === name && } + + ))} + + )} + + )} + {loading && } {error && {error}} - - + + {project + ? "Changes stay in a separate worktree on your server. Your project’s existing files are not modified." + : "Start a conversation now. Scratch chats run on your server without a project checkout."} + + + ); +} + +function ProjectRow(props: { + name: string; + selected: boolean; + disabled: boolean; + onPress: () => void; +}) { + return ( + + + {props.name} + + {props.selected && } + ); } + +const styles = StyleSheet.create({ + row: { + minHeight: 56, + flexDirection: "row", + alignItems: "center", + gap: spacing.md, + padding: spacing.lg, + }, + form: { padding: spacing.lg, gap: spacing.xl }, + footnote: { ...typography.footnote, color: colors.muted }, + branches: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm }, + branch: { + minHeight: 44, + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + maxWidth: "100%", + paddingHorizontal: spacing.md, + borderRadius: radii.control, + backgroundColor: colors.elevated, + }, + selectedBranch: { backgroundColor: colors.accentSurface }, +}); diff --git a/packages/mobile/src/screens/ModelSettings.tsx b/packages/mobile/src/screens/ModelSettings.tsx index 62e23fc5328..285e967155b 100644 --- a/packages/mobile/src/screens/ModelSettings.tsx +++ b/packages/mobile/src/screens/ModelSettings.tsx @@ -1,11 +1,21 @@ import { useState } from "react"; -import { Pressable, Text, View } from "react-native"; -import { Check } from "lucide-react-native"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Check, ChevronDown, ChevronRight, Cpu } from "lucide-react-native"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; +import { formatModelDisplayName } from "../../../../src/common/utils/ai/modelDisplay"; import { Button, Field, Sheet } from "../components/Controls"; import { modelChoices, resolveSettings, thinkingLevels } from "../settings"; import type { ChatSettings, SettingsData } from "../settings"; -import { colors, layout } from "../theme"; +import { colors, layout, radii, spacing, typography } from "../theme"; + +function modelName(id: string) { + return formatModelDisplayName( + id + .slice(id.indexOf(":") + 1) + .split("/") + .at(-1) ?? id + ); +} export function ModelSettings(props: { value: ChatSettings; @@ -16,113 +26,220 @@ export function ModelSettings(props: { }) { const [value, setValue] = useState(props.value); const [query, setQuery] = useState(""); + const [browsing, setBrowsing] = useState(false); + const [custom, setCustom] = useState(false); + const agents = props.data.agents.filter((agent) => agent.uiSelectable); const models = modelChoices(props.data, value.model).filter((model) => - model.toLowerCase().includes(query.toLowerCase()) + `${model} ${modelName(model)}`.toLowerCase().includes(query.trim().toLowerCase()) ); + const groups = new Map(); + for (const model of models) { + const provider = model.split(":")[0]; + const group = groups.get(provider) ?? []; + group.push(model); + groups.set(provider, group); + } + const validModel = /^\S+:\S+$/.test(value.model.trim()); + const currentAgent = agents.find((agent) => agent.id === value.agentId); return ( - - - Used for your next message. Providers and runtime configuration are managed on your Xum - server. - - AGENT - - {props.data.agents - .filter((agent) => agent.uiSelectable) - .map((agent) => ( - props.onSave({ ...value, model: value.model.trim() })} + > + Use settings + + } + > + + Agent + + {agents.map((agent) => ( + + + {currentAgent?.description ?? "Choose an agent for your next message."} + - MODEL - - {models.map((model) => ( - setValue({ ...value, model })} - /> - ))} - setValue({ ...value, model })} - placeholder="provider:model" - /> - - Enter a provider:model ID if it is not listed. Availability and reasoning support are - validated by the server. - - THINKING - - setValue({ ...value, thinkingLevel: undefined })} - /> - {thinkingLevels.map((level) => ( - setValue({ ...value, thinkingLevel: level })} + + Model + setBrowsing(!browsing)} + style={[layout.group, styles.modelRow]} + > + + + + {value.model ? modelName(value.model) : "Choose a model"} + + + {value.model.split(":")[0]} + + + + + + + Thinking + + + + Applies to your next message. Model capabilities are checked by your server. + + + {browsing && ( + + - ))} + {models.length === 0 ? ( + + No models match your search. Try another name or enter a custom model ID below. + + ) : ( + + {models.length} {models.length === 1 ? "model" : "models"} + + )} + {[...groups].map(([provider, choices]) => ( + + + {props.data.providers[provider]?.displayName ?? + provider[0].toUpperCase() + provider.slice(1)} + + + {choices.map((model, index) => ( + { + setValue({ ...value, model }); + setBrowsing(false); + setCustom(false); + }} + style={[styles.modelRow, index > 0 && styles.separator]} + > + + {modelName(model)} + + {model.slice(model.indexOf(":") + 1)} + + + {model === value.model && } + + ))} + + + ))} + + )} + + setCustom(!custom)} + style={styles.disclosure} + > + + Use a custom model ID + + {custom ? ( + + ) : ( + + )} + + {custom && ( + <> + setValue({ ...value, model })} + placeholder="provider:model" + returnKeyType="done" + /> + + {validModel + ? "Use a model supported by a configured server provider." + : "Enter a model in provider:model format."} + + + )} - ); } -function Choice(props: { - title: string; - description?: string; - selected: boolean; - onPress: () => void; -}) { +function Option(props: { label: string; selected: boolean; onPress: () => void }) { return ( - - - {props.title} - - {props.description && {props.description}} - - {props.selected && } + {props.selected && } + + {props.label} + ); } + +const styles = StyleSheet.create({ + section: { gap: spacing.sm }, + chips: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm }, + option: { + minHeight: 44, + paddingHorizontal: spacing.md, + borderRadius: radii.control, + backgroundColor: colors.panel, + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + }, + modelRow: { + minHeight: 64, + padding: spacing.lg, + flexDirection: "row", + alignItems: "center", + gap: spacing.md, + }, + separator: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border }, + footnote: { ...typography.footnote, color: colors.muted }, + disclosure: { minHeight: 44, flexDirection: "row", gap: spacing.sm, alignItems: "center" }, +}); diff --git a/packages/mobile/src/screens/SettingsScreen.tsx b/packages/mobile/src/screens/SettingsScreen.tsx index 9e0e1da2693..d87168c4c33 100644 --- a/packages/mobile/src/screens/SettingsScreen.tsx +++ b/packages/mobile/src/screens/SettingsScreen.tsx @@ -1,7 +1,8 @@ -import { Platform, ScrollView, Text, View } from "react-native"; -import { LogOut, Monitor, ShieldCheck } from "lucide-react-native"; -import { Button, Header, Notice } from "../components/Controls"; -import { colors, layout } from "../theme"; +import { useState } from "react"; +import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { ChevronRight, KeyRound, LogOut, Server, ShieldCheck } from "lucide-react-native"; +import { Button, Header, Notice, Sheet } from "../components/Controls"; +import { colors, layout, spacing, typography } from "../theme"; export function SettingsScreen(props: { endpoint: string; @@ -10,48 +11,116 @@ export function SettingsScreen(props: { error: string | null; busy: boolean; }) { + const [confirming, setConfirming] = useState(false); return (
- CONNECTION - - - - Your Xum server + + Connection + + + + + Xum server + + {props.endpoint} + + + + + + + + + {Platform.OS === "web" ? "This tab only" : "Secure device storage"} + + + {Platform.OS === "web" + ? "Your token is never saved in browser storage." + : "Your token is kept in the device’s credential store."} + + + - - {props.endpoint} - - - {Platform.OS === "web" - ? "Authentication is held in memory for this tab only." - : "Authentication is kept in this device’s secure credential storage."} - - ON THIS DEVICE - - - - A companion to your workspace + + Workspace + + + + + Runs on your server + + Agents, files, and commands stay on your connected Xum server. + + + + + + + + Managed on desktop + + Providers, runtimes, terminals, and project setup. Attachments are read-only here. + + + - - Agents, files, and commands run on your connected Xum server, not on this device. You - can chat, interrupt an agent, create a worktree or scratch chat, and read tracked - changes here. - - - Use Xum desktop for provider credentials, runtime provisioning, terminals, desktop - control, file editing, and project administration. Image and file attachments are - currently displayed as filenames only. - - {props.error && {props.error}} - - Disconnecting does not stop agents running on the server. + {props.error && !confirming && {props.error}} + + setConfirming(true)} + style={[layout.group, styles.row]} + > + + Disconnect + + + Disconnecting leaves your agents running. + + {confirming && ( + setConfirming(false)} + dismissDisabled={props.busy} + footer={ + <> + + + + } + > + + This removes the saved connection from this device. You’ll need your server URL and + token to reconnect. + + Your workspaces and running agents are not affected. + {props.error && {props.error}} + + )} ); } + +const styles = StyleSheet.create({ + section: { gap: spacing.sm }, + row: { + minHeight: 56, + padding: spacing.lg, + flexDirection: "row", + alignItems: "center", + gap: spacing.md, + }, + rowText: { flex: 1, minWidth: 0, gap: spacing.xs }, + footnote: { ...typography.footnote, color: colors.muted }, + separator: { height: StyleSheet.hairlineWidth, marginLeft: 48, backgroundColor: colors.border }, +}); diff --git a/packages/mobile/src/screens/formTestDom.ts b/packages/mobile/src/screens/formTestDom.ts new file mode 100644 index 00000000000..4de069900a8 --- /dev/null +++ b/packages/mobile/src/screens/formTestDom.ts @@ -0,0 +1,10 @@ +import "../testDom"; + +Object.assign(globalThis, { + ShadowRoot: window.ShadowRoot, + Node: window.Node, + Element: window.Element, + getComputedStyle: window.getComputedStyle.bind(window), + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), +}); diff --git a/packages/mobile/src/screens/formTestPlatform.ts b/packages/mobile/src/screens/formTestPlatform.ts new file mode 100644 index 00000000000..aaf6b131f2d --- /dev/null +++ b/packages/mobile/src/screens/formTestPlatform.ts @@ -0,0 +1,31 @@ +import "../testDom"; +import { mock } from "bun:test"; +// @ts-expect-error React Native Web publishes JS only; production types use React Native. +import * as NativeWeb from "react-native-web"; + +// Exercise real Pressable/TextInput/Modal behavior without loading native host modules. +// Insets and SVG painting are covered by the real-browser visual gate, not happy-dom. +mock.module("react-native", () => NativeWeb); +mock.module("react-native-safe-area-context", () => ({ SafeAreaView: NativeWeb.View })); +const icon = () => null; +mock.module("lucide-react-native", () => + Object.fromEntries( + [ + "AlertCircle", + "ChevronLeft", + "Info", + "TriangleAlert", + "X", + "Check", + "ChevronDown", + "ChevronRight", + "Cpu", + "Folder", + "MessageSquare", + "KeyRound", + "LogOut", + "Server", + "ShieldCheck", + ].map((name) => [name, icon]) + ) +); diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx new file mode 100644 index 00000000000..6f38cbea03d --- /dev/null +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -0,0 +1,180 @@ +import "./formTestPlatform"; +import { afterEach, expect, test } from "bun:test"; +import { createRef } from "react"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { createORPCClient } from "@orpc/client"; +import type { TextInput } from "react-native"; +import type { MobileClient } from "../api"; +import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; +import { Button, Field, Sheet } from "../components/Controls"; +import { CreateWorkspace } from "./CreateWorkspace"; +import { ModelSettings } from "./ModelSettings"; +import { SettingsScreen } from "./SettingsScreen"; +import type { ChatSettings, SettingsData } from "../settings"; + +afterEach(cleanup); + +const workspace: FrontendWorkspaceMetadata = { + id: "workspace", + name: "feature", + projectName: "project", + projectPath: "/project", + namedWorkspacePath: "/project/feature", + runtimeConfig: { type: "local" }, +}; + +test("a field ref focuses the next native input", () => { + const next = createRef(); + const view = render( + <> + next.current?.focus()} /> + + + ); + fireEvent.keyDown(view.getByLabelText("First"), { key: "Enter", keyCode: 13 }); + expect(document.activeElement).toBe(view.getByLabelText("Second")); +}); + +test("sheet contents and footer do not dismiss it; the backdrop does, unless dismissal is blocked", () => { + let dismissals = 0; + let submissions = 0; + const children = ; + const footer = ( + + ); + const view = render( + { + dismissals++; + }} + footer={footer} + > + {children} + + ); + fireEvent.click(view.getByLabelText("Title")); + fireEvent.click(view.getByRole("button", { name: "Submit" })); + expect(submissions).toBe(1); + expect(dismissals).toBe(0); + fireEvent.click(view.getByRole("button", { name: "Dismiss Example" })); + expect(dismissals).toBe(1); + view.rerender( + { + dismissals++; + }} + footer={footer} + > + {children} + + ); + fireEvent.click(view.getByRole("button", { name: "Dismiss Example" })); + fireEvent.click(view.getByRole("button", { name: "Close" })); + expect(dismissals).toBe(1); +}); + +test("workspace creation cannot be dismissed or submitted twice while the server is creating it", async () => { + let resolve!: (value: { success: true; metadata: FrontendWorkspaceMetadata }) => void; + const created = new Promise<{ success: true; metadata: FrontendWorkspaceMetadata }>((done) => { + resolve = done; + }); + let calls = 0; + let dismissals = 0; + let selected: FrontendWorkspaceMetadata | undefined; + const client = createORPCClient({ + call: async (path) => { + if (path.join(".") !== "workspace.createScratch") throw new Error("Unexpected procedure"); + calls++; + return created; + }, + }); + const view = render( + {}} + onClose={() => { + dismissals++; + }} + onCreated={(value) => { + selected = value; + }} + /> + ); + fireEvent.click(view.getByRole("button", { name: "Create scratch chat" })); + fireEvent.click(view.getByRole("button", { name: "Create scratch chat" })); + fireEvent.click(view.getByRole("button", { name: "Close" })); + fireEvent.click(view.getByRole("button", { name: "Dismiss New workspace" })); + expect(calls).toBe(1); + expect(dismissals).toBe(0); + await act(async () => { + resolve({ success: true, metadata: workspace }); + await created; + }); + expect(selected).toBe(workspace); +}); + +test("model search accepts friendly names and keeps the submit action available without clearing the selection", async () => { + const data: SettingsData = { + config: { agentAiDefaults: {}, defaultModel: "anthropic:claude-sonnet-4-5" }, + providers: { anthropic: { isConfigured: true, isEnabled: true, apiKeySet: true } }, + agents: [ + { id: "exec", name: "Exec", scope: "built-in", uiSelectable: true, subagentRunnable: true }, + ], + }; + let saved: ChatSettings | undefined; + const view = render( + {}} + onSave={(value) => { + saved = value; + }} + /> + ); + fireEvent.click(view.getByRole("button", { name: "Choose model" })); + fireEvent.change(view.getByLabelText("Search models"), { target: { value: "Sonnet 4.5" } }); + await waitFor(() => + expect(view.getByRole("button", { name: "anthropic:claude-sonnet-4-5" })).toBeDefined() + ); + fireEvent.change(view.getByLabelText("Search models"), { target: { value: "not-a-model" } }); + expect(view.queryByRole("button", { name: "anthropic:claude-sonnet-4-5" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Use settings" })); + expect(saved?.model).toBe("anthropic:claude-sonnet-4-5"); + expect(saved?.thinkingLevel).toBe("high"); +}); + +test("disconnect requires confirmation and can be cancelled without clearing credentials", () => { + let disconnects = 0; + const view = render( + {}} + onDisconnect={() => { + disconnects++; + }} + busy={false} + error={null} + /> + ); + fireEvent.click(view.getByRole("button", { name: "Disconnect" })); + expect(disconnects).toBe(0); + fireEvent.click(view.getByRole("button", { name: "Keep connection" })); + expect(disconnects).toBe(0); + fireEvent.click(view.getByRole("button", { name: "Disconnect" })); + fireEvent.click(view.getByRole("button", { name: "Disconnect & forget credentials" })); + expect(disconnects).toBe(1); +}); diff --git a/packages/mobile/src/screens/forms.test.ts b/packages/mobile/src/screens/forms.test.ts new file mode 100644 index 00000000000..4933a36a845 --- /dev/null +++ b/packages/mobile/src/screens/forms.test.ts @@ -0,0 +1,33 @@ +import { test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +// Native-web aliases must be installed before Bun parses RN's native host imports. +// Keep them in a child process so the transport/hook suite sees unmodified modules. +test("native form behavior", async () => { + const child = Bun.spawn( + [ + process.execPath, + "test", + "--preload", + "./src/screens/formTestDom.ts", + "--preload", + "./src/screens/formTestPlatform.ts", + "./src/screens/forms.behavior.tsx", + ], + { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + stdout: "pipe", + stderr: "pipe", + } + ); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) throw new Error(`Native form tests failed (${code}):\n${stdout}\n${stderr}`); + } finally { + if (child.exitCode === null) child.kill(); + } +}, 30_000); diff --git a/packages/mobile/src/theme.ts b/packages/mobile/src/theme.ts index 6272efdbcd2..c5a3acb88ea 100644 --- a/packages/mobile/src/theme.ts +++ b/packages/mobile/src/theme.ts @@ -11,20 +11,41 @@ export const colors = { muted: "hsl(240, 5%, 65%)", dim: "hsl(240, 5%, 34%)", accent: "hsl(268.56, 90%, 68%)", + accentSurface: "hsla(268.56, 90%, 68%, 0.12)", plan: "hsl(210, 70%, 68%)", danger: "hsl(0, 91%, 71%)", + dangerSurface: "hsla(0, 91%, 71%, 0.10)", + warning: "hsl(38, 80%, 65%)", + warningSurface: "hsla(38, 80%, 65%, 0.10)", success: "hsl(142, 76%, 46%)", user: "hsla(0, 0%, 100%, 0.06)", + scrim: "hsla(240, 10%, 4%, 0.72)", }; +export const spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24, xxxl: 32 }; +export const radii = { control: 14, card: 18, sheet: 24, pill: 999 }; +export const typography = StyleSheet.create({ + title: { fontSize: 24, lineHeight: 30, fontWeight: "600", letterSpacing: -0.5 }, + header: { fontSize: 17, lineHeight: 22, fontWeight: "600" }, + body: { fontSize: 17, lineHeight: 24 }, + secondary: { fontSize: 15, lineHeight: 22 }, + footnote: { fontSize: 13, lineHeight: 18 }, +}); export const mono = Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" }); export const layout = StyleSheet.create({ fill: { flex: 1, backgroundColor: colors.background }, - row: { flexDirection: "row", alignItems: "center", gap: 8 }, - text: { color: colors.text, fontSize: 15, lineHeight: 23 }, - muted: { color: colors.muted, fontSize: 13, lineHeight: 20 }, - title: { color: colors.bright, fontSize: 20, fontWeight: "600" }, - label: { color: colors.muted, fontSize: 12, fontWeight: "600", letterSpacing: 1 }, - content: { padding: 20, gap: 20, width: "100%", maxWidth: 760, alignSelf: "center" }, - divider: { height: 1, backgroundColor: colors.border }, + row: { flexDirection: "row", alignItems: "center", gap: spacing.sm }, + text: { ...typography.body, color: colors.text }, + muted: { ...typography.secondary, color: colors.muted }, + title: { ...typography.title, color: colors.bright }, + label: { ...typography.footnote, color: colors.muted, fontWeight: "600" }, + content: { + padding: spacing.xl, + gap: spacing.xxl, + width: "100%", + maxWidth: 760, + alignSelf: "center", + }, + divider: { height: StyleSheet.hairlineWidth, backgroundColor: colors.border }, + group: { backgroundColor: colors.panel, borderRadius: radii.card, overflow: "hidden" }, }); From 41e39b0148dbdc59baa31c77ca688f5fe9891fde Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 17:08:03 +0000 Subject: [PATCH 07/84] =?UTF-8?q?=F0=9F=A4=96=20feat:=20refine=20native=20?= =?UTF-8?q?mobile=20navigation=20and=20conversation=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual navigation with a native stack, preserve drafts and selections, and refine conversation, workspace, model, and changes layouts. Add pinned-viewport browser regressions, including composer growth/shrink and navigation retention. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$148.33`_ --- Makefile | 5 +- docs/integrations/mobile-app.md | 16 +- packages/mobile/App.tsx | 361 +++++++++++------- packages/mobile/bun.lock | 56 +++ packages/mobile/e2e/native-ux.spec.ts | 106 +++++ packages/mobile/package.json | 4 + packages/mobile/playwright.config.ts | 25 ++ packages/mobile/src/components/Controls.tsx | 2 + packages/mobile/src/components/Markdown.tsx | 8 +- packages/mobile/src/components/Message.tsx | 73 +++- packages/mobile/src/screens/ChangesScreen.tsx | 135 +++++-- .../mobile/src/screens/ConversationScreen.tsx | 238 +++++++----- packages/mobile/src/screens/ModelSettings.tsx | 136 ++++--- packages/mobile/src/screens/Navigator.tsx | 289 +++++++++----- .../mobile/src/screens/forms.behavior.tsx | 3 +- packages/mobile/src/useProjects.ts | 13 +- packages/mobile/tsconfig.json | 15 +- .../builtInSkillContent.generated.ts | 16 +- 18 files changed, 1068 insertions(+), 433 deletions(-) create mode 100644 packages/mobile/e2e/native-ux.spec.ts create mode 100644 packages/mobile/playwright.config.ts diff --git a/Makefile b/Makefile index 03b74ad5528..46f82b89aba 100644 --- a/Makefile +++ b/Makefile @@ -591,7 +591,7 @@ test-storybook: node_modules/.installed ## Run Storybook interaction tests (requ ## React Native companion (isolated Expo dependency graph) MOBILE_METRO_PORT ?= 8081 -.PHONY: mobile-install mobile-web mobile-native mobile-preview mobile-export mobile-export-ios mobile-typecheck mobile-test mobile-lint mobile-fmt mobile-check +.PHONY: mobile-install mobile-web mobile-native mobile-preview mobile-export mobile-export-ios mobile-typecheck mobile-test mobile-test-web mobile-lint mobile-fmt mobile-check mobile-install: packages/mobile/node_modules/.installed ## Install pinned mobile dependencies packages/mobile/node_modules/.installed: packages/mobile/package.json packages/mobile/bun.lock @@ -628,6 +628,9 @@ mobile-typecheck: mobile-install ## Typecheck the mobile app against shared Xum mobile-test: mobile-install packages/mobile/.expo/preview.mjs ## Test mobile protocol, transcript, and preview safety @cd packages/mobile && bun test src scripts +mobile-test-web: mobile-install ## Test native-web navigation/layout against a disposable running preview + @cd packages/mobile && bun x playwright test + mobile-lint: mobile-install ## Lint native components and mobile infrastructure @cd packages/mobile && ../../node_modules/.bin/eslint . --max-warnings 0 diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index ce42fa7e38b..1b17d154dad 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -5,7 +5,7 @@ description: Develop the React Native Xum companion and connect it to your serve The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website. -It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. The project/workspace navigator, bottom composer, and full-screen workspace panels follow Xum's mobile navigation. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. +It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation and model settings use sheets with pinned actions. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. ## Connect to a server @@ -27,7 +27,7 @@ make mobile-install XUM_MOBILE_ENDPOINT=http://127.0.0.1:3000 make mobile-web ``` -Open `http://127.0.0.1:8082`, then enter that configured **server endpoint** and its token. Metro runs on port 8081; use the proxy on 8082, not Metro's direct URL, for API access. +Open `http://127.0.0.1:8082` in a current Chromium browser, then enter that configured **server endpoint** and its token. Metro runs on port 8081; use the proxy on 8082, not Metro's direct URL, for API access. The web preview uses CSS content sizing for the composer; native builds use React Native's text measurement. The preview forwards to exactly one endpoint configured at startup. It checks the request Host and Origin before forwarding, strips preview cookies/forwarded identity, and preserves the upstream path prefix. It does not relax the production server's origin protections. Native builds connect directly and do not need this proxy. @@ -81,6 +81,18 @@ bun test ./scripts/server.integration.test.ts That test creates and removes a scratch workspace. It exercises real authentication, persistence, streaming and reconnect/replay; only the model response is deterministic. +With the production preview running against that same disposable server, run the full-app browser regressions from the repository root: + +```bash +# One-time browser installation +(cd packages/mobile && bun x playwright install chromium) +XUM_MOBILE_TEST_ENDPOINT=http://127.0.0.1:3000 \ +XUM_MOBILE_TEST_TOKEN=your-disposable-server-token \ +make mobile-test-web +``` + +These tests pin 375px, 390px, and 1200px viewports, create and remove scratch chats, and check draft/model retention, keyboard focus, and reachable sheet actions. Set `XUM_MOBILE_TEST_WEB_URL` if the preview is not at `http://127.0.0.1:8082`. + For a browser walkthrough, use the production preview and a phone viewport around 375–390 pixels, then repeat at tablet/desktop width: 1. Check invalid URL, wrong token, and successful connection. diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx index 94ef341f29a..319bc9e4bcb 100644 --- a/packages/mobile/App.tsx +++ b/packages/mobile/App.tsx @@ -1,7 +1,11 @@ -import { useState } from "react"; -import { Modal, StatusBar, Text, useWindowDimensions, View } from "react-native"; +import { createContext, useContext, useState } from "react"; +import type { ReactNode, SetStateAction } from "react"; +import { StatusBar, Text, useWindowDimensions, View } from "react-native"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; -import { Menu, Plus } from "lucide-react-native"; +import { DarkTheme, NavigationContainer } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import type { FrontendWorkspaceMetadata } from "../../src/common/types/workspace"; import { clearCredentials } from "./src/credentials"; import { ConnectScreen } from "./src/screens/ConnectScreen"; import type { Connection } from "./src/screens/ConnectScreen"; @@ -10,39 +14,66 @@ import { ConversationScreen } from "./src/screens/ConversationScreen"; import { CreateWorkspace } from "./src/screens/CreateWorkspace"; import { ChangesScreen } from "./src/screens/ChangesScreen"; import { SettingsScreen } from "./src/screens/SettingsScreen"; -import { Button, Header, IconButton, Loading, Notice } from "./src/components/Controls"; +import { Button, Header, Loading, Notice } from "./src/components/Controls"; import { useProjects } from "./src/useProjects"; import { useConnection } from "./src/useConnection"; import { colors, layout } from "./src/theme"; +import type { ChatSettings } from "./src/settings"; + +export type MobileRoutes = { + Workspaces: undefined; + Conversation: { workspaceId: string }; + Changes: { workspaceId: string }; + Settings: undefined; +}; +const Stack = createNativeStackNavigator(); + +type SessionContext = { + session: ReturnType; + data: ReturnType; + drafts: Record; + selections: Record; + setSelection: (id: string, value: ChatSettings) => void; + setDraft: (id: string, update: SetStateAction) => void; + create: (onCreated: (workspace: FrontendWorkspaceMetadata) => void) => void; + disconnect: () => Promise; + disconnectError: string | null; + disconnecting: boolean; +}; +const Session = createContext(null); +function useSession() { + const session = useContext(Session); + if (!session) throw new Error("Mobile screens require an authenticated session"); + return session; +} export default function App() { const [connection, setConnection] = useState(null); return ( - - {connection ? ( - setConnection(null)} /> - ) : ( + {connection ? ( + setConnection(null)} /> + ) : ( + - )} - + + )} ); } function ConnectedApp(props: { connection: Connection; onDisconnect: () => void }) { - const { width } = useWindowDimensions(); - const wide = width >= 900; const session = useConnection(props.connection); const data = useProjects(session.connection.client, session.signal); - const [selectedId, setSelectedId] = useState(null); - const [drawer, setDrawer] = useState(false); - const [create, setCreate] = useState(false); - const [screen, setScreen] = useState<"chat" | "changes" | "settings">("chat"); + // Draft text and unsent model choices survive native back/pop and reconnection. + const [drafts, setDrafts] = useState>({}); + const [selections, setSelections] = useState>({}); + const [onCreated, setOnCreated] = useState< + ((workspace: FrontendWorkspaceMetadata) => void) | null + >(null); const [disconnectError, setDisconnectError] = useState(null); const [disconnecting, setDisconnecting] = useState(false); - const selected = data.workspaces.find((workspace) => workspace.id === selectedId); async function disconnect() { session.cancel(); setDisconnecting(true); @@ -51,134 +82,208 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void await clearCredentials(); props.onDisconnect(); } catch { - setDisconnectError( - "Could not clear secure credentials. Try again before leaving this device." - ); + setDisconnectError("Could not clear saved credentials. Try disconnecting again."); setDisconnecting(false); } } - const navigation = ( + const value: SessionContext = { + session, + data, + drafts, + selections, + setSelection(id, settings) { + setSelections((current) => ({ ...current, [id]: settings })); + }, + disconnect, + disconnectError, + disconnecting, + setDraft(id, update) { + setDrafts((current) => { + const next = typeof update === "function" ? update(current[id] ?? "") : update; + return current[id] === next ? current : { ...current, [id]: next }; + }); + }, + create(callback) { + if (session.ready) setOnCreated(() => callback); + }, + }; + return ( + + + + + params.workspaceId} + /> + + + + + {onCreated && ( + setOnCreated(null)} + onCreated={(workspace) => { + // Navigation can render immediately; use the server-returned metadata before re-listing. + data.addWorkspace(workspace); + data.retry(); + onCreated(workspace); + setOnCreated(null); + }} + /> + )} + + ); +} + +function WorkspaceList(props: { + selectedId?: string; + onSelect: (id: string) => void; + onSettings: () => void; + compact?: boolean; +}) { + const { data, session, create } = useSession(); + return ( { - setSelectedId(workspace.id); - setDrawer(false); - setScreen("chat"); - }} - onCreate={() => { - setDrawer(false); - if (session.ready) setCreate(true); - }} - onSettings={() => { - setDrawer(false); - setScreen("settings"); - }} - onClose={wide ? undefined : () => setDrawer(false)} + onSelect={(workspace) => props.onSelect(workspace.id)} + onCreate={() => create((workspace) => props.onSelect(workspace.id))} + onSettings={props.onSettings} /> ); +} + +function WorkspacesRoute(props: NativeStackScreenProps) { return ( - - {wide && {navigation}} - - {session.reconnecting && } - {session.error && {session.error}} - - {selected ? ( - setDrawer(true)} - onChanges={() => setScreen("changes")} - /> - ) : ( - <> -
setDrawer(true)} - /> - ) - } - /> - - {data.loading ? ( - - ) : data.error ? ( - {data.error} - ) : ( - <> - Make space for your next idea. - - Select a workspace or start a new conversation. Everything stays on your Xum - server. - - - - )} - - - )} - - {screen === "changes" && selected && ( - setScreen("chat")} - /> - )} - {screen === "settings" && ( - setScreen("chat")} - error={disconnectError} - busy={disconnecting} + + props.navigation.navigate("Conversation", { workspaceId })} + onSettings={() => props.navigation.navigate("Settings")} + /> + + ); +} + +function ScreenLayout(props: { + children: ReactNode; + workspaceId?: string; + navigation: Pick["navigation"], "navigate">; +}) { + const { width } = useWindowDimensions(); + return ( + + {width >= 900 && ( + + props.navigation.navigate("Conversation", { workspaceId })} + onSettings={() => props.navigation.navigate("Settings")} /> - )} - - {!wide && drawer && ( - setDrawer(false)}> - {navigation} - + )} - {create && ( - {props.children} + + ); +} + +function ConversationRoute(props: NativeStackScreenProps) { + const { session, data, drafts, setDraft, selections, setSelection } = useSession(); + const { workspaceId } = props.route.params; + const workspace = data.workspaces.find((item) => item.id === workspaceId); + return ( + + {session.reconnecting && } + {session.error && {session.error}} + {workspace ? ( + setCreate(false)} - onCreated={(workspace) => { - setSelectedId(workspace.id); - setScreen("chat"); - setCreate(false); - data.retry(); - }} + onBack={() => props.navigation.popTo("Workspaces")} + onChanges={() => props.navigation.navigate("Changes", { workspaceId })} + selection={selections[workspaceId] ?? null} + onSelectionChange={(value) => setSelection(workspaceId, value)} + draft={drafts[workspaceId] ?? ""} + onDraftChange={(update) => setDraft(workspaceId, update)} /> + ) : ( + <> +
props.navigation.goBack()} /> + {data.loading ? ( + + ) : ( + + This workspace is no longer available. + + + )} + )} - + + ); +} + +function ChangesRoute(props: NativeStackScreenProps) { + const { session } = useSession(); + return ( + + props.navigation.goBack()} + /> + + ); +} + +function SettingsRoute(props: NativeStackScreenProps) { + const { session, disconnect, disconnectError, disconnecting } = useSession(); + return ( + + props.navigation.goBack()} + error={disconnectError} + busy={disconnecting} + /> + ); } diff --git a/packages/mobile/bun.lock b/packages/mobile/bun.lock index fa01f379a4e..317867eaa87 100644 --- a/packages/mobile/bun.lock +++ b/packages/mobile/bun.lock @@ -6,6 +6,8 @@ "dependencies": { "@expo/metro-runtime": "~57.0.15", "@orpc/client": "1.14.11", + "@react-navigation/native": "^7", + "@react-navigation/native-stack": "^7", "expo": "~57.0.20", "expo-secure-store": "~57.0.3", "expo-status-bar": "~57.0.1", @@ -14,12 +16,14 @@ "react-dom": "19.2.3", "react-native": "0.86.3", "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", "web-streams-polyfill": "4.2.0", }, "devDependencies": { "@orpc/contract": "1.14.11", + "@playwright/test": "1.57.0", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/bun": "^1.3.5", @@ -253,6 +257,8 @@ "@orpc/shared": ["@orpc/shared@1.14.11", "", { "dependencies": { "@standardserver/shared": "^0.5.0", "radash": "^12.1.1", "type-fest": "^5.3.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-TNDa578Deju+vrCWEE/IbnDDsE/x2aEfOYVLUy+YPusZ9gx3FagcS9uxaBkJ2bdMrHEo1n5P0SjKdiEQttj9CQ=="], + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@react-native/assets-registry": ["@react-native/assets-registry@0.86.3", "", {}, "sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q=="], "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.86.3", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.86.3" } }, "sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ=="], @@ -275,6 +281,16 @@ "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.86.3", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.86.3" }, "optionalPeers": ["@types/react"] }, "sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw=="], + "@react-navigation/core": ["@react-navigation/core@7.21.13", "", { "dependencies": { "@react-navigation/routers": "^7.6.4", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-d1syMXra7RlJjGsjsIxEA14F6U+awFtQJeOfDc+V7x3y1mPqYQVTYnqnt3PR+fBBouUKaKsgKZsSR3MP5919Xg=="], + + "@react-navigation/elements": ["@react-navigation/elements@2.9.40", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.3.18", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-x2fCMVKU23YeQvHSCFq2mVRYAbHqYNqb8kfWSEfIQ+XUX7dBXWd93/pr0w5jLjALDHDxQUaJKh4mIqr8q3zAwQ=="], + + "@react-navigation/native": ["@react-navigation/native@7.3.18", "", { "dependencies": { "@react-navigation/core": "^7.21.13", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "standard-navigation": "^0.0.8", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-5lLYl0Kt85jcpEYxXzj2ecIKta+yo4EcAIOZoJe6jU9lbR0lu6sAQsotU3rlVAYL1VBY/xLemxs4WQ2LJs4Oew=="], + + "@react-navigation/native-stack": ["@react-navigation/native-stack@7.18.10", "", { "dependencies": { "@react-navigation/elements": "^2.9.40", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.3.18", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-dWH6bLHxdPslMmNCPi8doythC9gtavvh9LK8F0HIQLuYE5A2GOk1u6mrvnYrqyJBfsXa6gA8O0Zr6XGywynDnA=="], + + "@react-navigation/routers": ["@react-navigation/routers@7.6.4", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-GI7eJm8/KsZUQaYcXvEExikKurRZRgEsSzyZ7faENfi65yqJBCXjDMwyN1pF6pNW1MoLH1ErDwDivFxY6BzD3w=="], + "@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -409,10 +425,14 @@ "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], @@ -443,6 +463,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], @@ -519,6 +541,8 @@ "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="], "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], @@ -533,6 +557,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="], + "finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], @@ -543,6 +569,8 @@ "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -585,6 +613,8 @@ "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -767,6 +797,10 @@ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], @@ -785,6 +819,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], + "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -795,12 +831,16 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-native": ["react-native@0.86.3", "", { "dependencies": { "@react-native/assets-registry": "0.86.3", "@react-native/codegen": "0.86.3", "@react-native/community-cli-plugin": "0.86.3", "@react-native/gradle-plugin": "0.86.3", "@react-native/js-polyfills": "0.86.3", "@react-native/normalize-colors": "0.86.3", "@react-native/virtualized-lists": "0.86.3", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.36.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.17", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.86.3", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q=="], "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], + "react-native-screens": ["react-native-screens@4.26.2", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A=="], + "react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="], "react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="], @@ -851,6 +891,8 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sf-symbols-typescript": ["sf-symbols-typescript@2.2.0", "", {}, "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -861,6 +903,8 @@ "simple-plist": ["simple-plist@1.3.1", "", { "dependencies": { "bplist-creator": "0.1.0", "bplist-parser": "0.3.1", "plist": "^3.0.5" } }, "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw=="], + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], @@ -871,14 +915,20 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], + "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], "stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="], + "standard-navigation": ["standard-navigation@0.0.8", "", { "peerDependencies": { "react": "*" } }, "sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="], + "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], @@ -933,6 +983,10 @@ "update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="], + "use-latest-callback": ["use-latest-callback@0.2.6", "", { "peerDependencies": { "react": ">=16.8" } }, "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], @@ -1009,6 +1063,8 @@ "@react-native/codegen/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + "@react-navigation/core/react-is": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/packages/mobile/e2e/native-ux.spec.ts b/packages/mobile/e2e/native-ux.spec.ts new file mode 100644 index 00000000000..b473b197c7b --- /dev/null +++ b/packages/mobile/e2e/native-ux.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; +import { connect } from "../src/api"; + +async function withinViewport(page: Page, locator: Locator) { + const viewport = page.viewportSize()!; + // Native sheets animate into place; assert settled geometry, not the first animation frame. + await expect + .poll(async () => { + const bounds = await locator.boundingBox(); + return bounds != null && bounds.y >= 0 && bounds.y + bounds.height <= viewport.height; + }) + .toBe(true); +} + +test("native stack preserves drafts and sheets keep their actions reachable", async ({ + page, +}, info) => { + const endpoint = process.env.XUM_MOBILE_TEST_ENDPOINT!; + const token = process.env.XUM_MOBILE_TEST_TOKEN!; + const title = `Mobile UX check ${info.project.name} ${Date.now()}`; + try { + await page.goto("/"); + await page.getByRole("textbox", { name: "Server URL" }).fill(endpoint); + await page.getByRole("textbox", { name: "Server URL" }).press("Enter"); + await expect(page.getByRole("textbox", { name: "Bearer token", exact: true })).toBeFocused(); + await page.getByRole("textbox", { name: "Bearer token", exact: true }).fill(token); + await page.getByRole("button", { name: /^Connect(?: without encryption)?$/ }).click(); + await expect(page.getByRole("textbox", { name: "Search workspaces" })).toBeVisible(); + + await page.getByRole("button", { name: "New workspace", exact: true }).first().click(); + const create = page.getByRole("button", { name: "Create scratch chat", exact: true }); + await withinViewport(page, create); + await page.getByRole("textbox", { name: "Title (optional)", exact: true }).fill(title); + await create.click(); + await expect(page.getByRole("textbox", { name: "Message", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); + const message = page.getByRole("textbox", { name: "Message", exact: true }); + await message.fill("A line of a longer draft\n".repeat(10)); + await expect.poll(async () => (await message.boundingBox())!.height).toBeGreaterThan(80); + await message.fill(""); + await expect.poll(async () => (await message.boundingBox())!.height).toBeLessThanOrEqual(50); + const draft = "Keep this unsent draft while navigating."; + await message.fill(draft); + const send = page.getByRole("button", { name: "Send message", exact: true }); + await expect(send).toBeEnabled(); + await withinViewport(page, send); + await page.getByRole("button", { name: "Back to workspaces", exact: true }).click(); + await page.getByRole("textbox", { name: "Search workspaces", exact: true }).fill(title); + await page.getByRole("button", { name: title, exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveValue(draft); + + await page.getByRole("button", { name: "Choose model, agent, and thinking" }).click(); + const apply = page.getByRole("button", { name: "Use settings", exact: true }); + await withinViewport(page, apply); + await page.getByRole("button", { name: "Choose model", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible(); + await page.getByRole("textbox", { name: "Search models" }).fill("no-match-model-query"); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await withinViewport(page, apply); + await page.getByRole("button", { name: "Plan", exact: true }).click(); + await apply.click(); + await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveValue(draft); + await expect( + page.getByRole("button", { name: "Choose model, agent, and thinking" }) + ).toContainText("Plan"); + await page.getByRole("button", { name: "Back to workspaces", exact: true }).click(); + await page.getByRole("button", { name: title, exact: true }).click(); + await expect( + page.getByRole("button", { name: "Choose model, agent, and thinking" }) + ).toContainText("Plan"); + + await message.fill(""); + const viewport = page.viewportSize()!; + await page.setViewportSize({ + width: viewport.width === 1200 ? 375 : 1200, + height: viewport.height, + }); + await expect.poll(async () => (await message.boundingBox())!.height).toBeLessThanOrEqual(50); + await page.setViewportSize(viewport); + await expect.poll(async () => (await message.boundingBox())!.height).toBeLessThanOrEqual(50); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( + true + ); + expect( + await page.evaluate( + (secret) => + [...Object.values(localStorage), ...Object.values(sessionStorage)].some((value) => + value.includes(secret) + ), + token + ) + ).toBe(false); + } finally { + // Cleanup only the uniquely named scratch chat created by this test, even on failure. + const connection = await connect(endpoint, token); + try { + const workspace = (await connection.client.workspace.list()).find( + (item) => item.title === title && item.kind === "scratch" + ); + if (workspace) await connection.client.workspace.remove({ workspaceId: workspace.id }); + } finally { + connection.close(); + } + } +}); diff --git a/packages/mobile/package.json b/packages/mobile/package.json index f98ae00698a..51ff9538006 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -8,6 +8,8 @@ "dependencies": { "@expo/metro-runtime": "~57.0.15", "@orpc/client": "1.14.11", + "@react-navigation/native": "^7", + "@react-navigation/native-stack": "^7", "expo": "~57.0.20", "expo-secure-store": "~57.0.3", "expo-status-bar": "~57.0.1", @@ -16,12 +18,14 @@ "react-dom": "19.2.3", "react-native": "0.86.3", "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", "web-streams-polyfill": "4.2.0" }, "devDependencies": { "@orpc/contract": "1.14.11", + "@playwright/test": "1.57.0", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/bun": "^1.3.5", diff --git a/packages/mobile/playwright.config.ts b/packages/mobile/playwright.config.ts new file mode 100644 index 00000000000..82928d1057b --- /dev/null +++ b/packages/mobile/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "@playwright/test"; + +// Tests create disposable scratch chats. Never silently point this at a user's server. +if (!process.env.XUM_MOBILE_TEST_ENDPOINT || !process.env.XUM_MOBILE_TEST_TOKEN) { + throw new Error( + "Set XUM_MOBILE_TEST_ENDPOINT and XUM_MOBILE_TEST_TOKEN for a disposable server." + ); +} +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + workers: 1, + reporter: "list", + outputDir: process.env.XUM_MOBILE_TEST_ARTIFACTS ?? ".expo/test-results", + use: { + baseURL: process.env.XUM_MOBILE_TEST_WEB_URL ?? "http://127.0.0.1:8082", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { name: "phone-375", use: { viewport: { width: 375, height: 812 } } }, + { name: "phone-390", use: { viewport: { width: 390, height: 844 } } }, + { name: "wide", use: { viewport: { width: 1200, height: 900 } } }, + ], +}); diff --git a/packages/mobile/src/components/Controls.tsx b/packages/mobile/src/components/Controls.tsx index 73dab470977..5a26c238468 100644 --- a/packages/mobile/src/components/Controls.tsx +++ b/packages/mobile/src/components/Controls.tsx @@ -179,6 +179,7 @@ export function Sheet(props: { onClose: () => void; footer?: ReactNode; dismissDisabled?: boolean; + onBack?: () => void; }) { function dismiss() { if (!props.dismissDisabled) props.onClose(); @@ -216,6 +217,7 @@ export function Sheet(props: { >
) => Promise; }) { const user = props.message.role === "user"; return ( - - {user ? "YOU" : props.message.role === "assistant" ? "XUM" : "SYSTEM"} - + {!user && ( + {props.message.role === "assistant" ? "Xum" : "System"} + )} {props.message.parts.map((part, index) => { switch (part.type) { case "text": @@ -46,6 +47,15 @@ export function Message(props: { ); } })} + {!user && + props.message.metadata?.partial && + !props.streaming && + !props.message.metadata.error && ( + + + Interrupted + + )} ); } @@ -76,10 +86,12 @@ function Disclosure(props: { label: string; reasoning?: boolean; children: React ); } +const MAX_TOOL_CHARACTERS = 24_000; + function printable(value: unknown): string { return ( typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "No output") - ).slice(0, 24000); + ).slice(0, MAX_TOOL_CHARACTERS); } function Tool(props: { @@ -94,23 +106,27 @@ function Tool(props: { return ( letter.toUpperCase())} · ${props.part.state === "input-available" ? "running" : props.part.state === "output-redacted" ? "redacted" : "done"}`} > - INPUT + Input {printable(props.part.input)} {props.part.state === "output-available" && ( <> - OUTPUT + Output {printable(props.part.output)} )} - - Large tool details are limited to 24,000 characters on mobile. - + {(printable(props.part.input).length === MAX_TOOL_CHARACTERS || + (props.part.state === "output-available" && + printable(props.part.output).length === MAX_TOOL_CHARACTERS)) && ( + + Showing the first {MAX_TOOL_CHARACTERS.toLocaleString()} characters. + + )} {questions.length > 0 && ( - YOUR INPUT IS NEEDED + Your input is needed {props.questions.map((question) => ( controller.abort(); }, [props.client, props.workspaceId, props.signal, generation]); + const files = output?.split(/(?=^diff --git )/m).filter(Boolean) ?? []; return (
} /> - - - Read-only. Includes staged and unstaged tracked files; untracked files and changes already - committed are not included. - + {error && {error}} {output === null && !error && } {note && {note}} - {output === "" && No tracked changes against HEAD.} - {output && ( - - - {output.split("\n").map((line, index) => ( - - {line || " "} + {output === "" && ( + + + No uncommitted changes + + Tracked files match the latest commit. + + + )} + {files.length > 0 && ( + + {files.length} changed {files.length === 1 ? "file" : "files"} + + )} + {files.map((file, index) => { + const lines = file.trimEnd().split("\n"); + const filename = + lines + .find((line) => line.startsWith("+++ ")) + ?.slice(4) + .replace(/^b\//, "") ?? lines[0].replace(/^diff --git /, ""); + const content = lines.filter((line) => !/^(diff --git |index |--- |\+\+\+ )/.test(line)); + const additions = content.filter((line) => line.startsWith("+")).length; + const deletions = content.filter((line) => line.startsWith("-")).length; + return ( + + + + + {filename} - ))} + +{additions} + −{deletions} + + + + {content.map((line, lineIndex) => ( + + {line || " "} + + ))} + + - + ); + })} + {output !== null && ( + + Staged and unstaged tracked files, compared with HEAD. Untracked files and committed + changes aren’t shown. + )} ); } + +const styles = StyleSheet.create({ + content: { + padding: spacing.xl, + gap: 16, + width: "100%", + maxWidth: 900, + alignSelf: "center", + flexGrow: 1, + }, + empty: { alignItems: "center", justifyContent: "center", gap: 12, paddingVertical: 56 }, + file: { + borderRadius: radii.card, + overflow: "hidden", + backgroundColor: colors.panel, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + fileHeader: { + minHeight: 52, + paddingHorizontal: 14, + flexDirection: "row", + alignItems: "center", + gap: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + filename: { flex: 1, minWidth: 0, color: colors.bright, fontSize: 15, fontWeight: "500" }, + count: { fontSize: 12, fontVariant: ["tabular-nums"] }, + code: { fontFamily: mono, fontSize: 13, lineHeight: 21 }, + footnote: { ...typography.footnote, color: colors.muted }, +}); diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 4e9d5d50be2..df185eb894e 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import type { SetStateAction } from "react"; import { FlatList, KeyboardAvoidingView, @@ -14,7 +15,7 @@ import { ArrowUp, ChevronDown, GitCompareArrows, - Menu, + ChevronLeft, Square, } from "lucide-react-native"; import type { MobileClient } from "../api"; @@ -27,16 +28,25 @@ import { linkedAbortController } from "../useConnection"; import { resolveSettings } from "../settings"; import type { ChatSettings } from "../settings"; import { ModelSettings } from "./ModelSettings"; -import { colors, layout } from "../theme"; +import { colors, layout, radii, spacing, typography } from "../theme"; +import { formatModelDisplayName } from "../../../../src/common/utils/ai/modelDisplay"; import { DEFAULT_THINKING_LEVEL } from "../../../../src/common/types/thinking"; +// RN Web reports scrollHeight, which cannot shrink a fixed-height textarea and +// can expand hidden stack screens. Let the browser size content; native uses its intrinsic measurement. +const webInputSizing = { fieldSizing: "content", height: "auto" } as const; + export function ConversationScreen(props: { client: MobileClient; workspace: FrontendWorkspaceMetadata; signal: AbortSignal; connected: boolean; onReconnect: () => Promise; - onMenu?: () => void; + onBack: () => void; + selection: ChatSettings | null; + onSelectionChange: (value: ChatSettings) => void; + draft: string; + onDraftChange: (value: SetStateAction) => void; onChanges: () => void; }) { const { transcript, settings, error, loadOlder, loadingOlder, historyError } = useConversation( @@ -44,12 +54,14 @@ export function ConversationScreen(props: { props.workspace.id, props.signal ); - const [overrides, setOverrides] = useState(null); - const [draft, setDraft] = useState(""); + const draft = props.draft; + const setDraft = props.onDraftChange; + const [inputHeight, setInputHeight] = useState(44); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); const [showSettings, setShowSettings] = useState(false); const [atBottom, setAtBottom] = useState(true); + const [composerHeight, setComposerHeight] = useState(100); const list = useRef>(null); const controller = useRef(new AbortController()); const pending = useRef(false); @@ -64,7 +76,7 @@ export function ConversationScreen(props: { }, [props.signal]); const agentId = props.workspace.agentId ?? "exec"; const options = - overrides ?? (settings ? resolveSettings(props.workspace, settings, agentId) : null); + props.selection ?? (settings ? resolveSettings(props.workspace, settings, agentId) : null); const ready = props.connected && !props.signal.aborted && transcript.caughtUp && !error && settings !== null; const running = ready && transcript.streaming; @@ -92,6 +104,7 @@ export function ConversationScreen(props: { typeof result.error === "string" ? result.error : JSON.stringify(result.error) ); setDraft((current) => (current === message ? "" : current)); + setInputHeight(44); list.current?.scrollToEnd({ animated: true }); } catch (cause) { if (!signal.aborted) @@ -144,12 +157,17 @@ export function ConversationScreen(props: { behavior={Platform.OS === "ios" ? "padding" : "height"} > - {props.onMenu && } + {props.workspace.title ?? props.workspace.name} - + {props.workspace.kind === "scratch" ? "Scratch chat" : props.workspace.name} ·{" "} {props.workspace.runtimeConfig.type} @@ -194,7 +212,12 @@ export function ConversationScreen(props: { if (atBottom) list.current?.scrollToEnd({ animated: false }); }} renderItem={({ item }) => ( - + )} ListEmptyComponent={ !ready && !error ? ( @@ -219,7 +242,7 @@ export function ConversationScreen(props: { } /> {!atBottom && ( - + )} - + setComposerHeight(event.nativeEvent.layout.height)} + > {actionError && ( { @@ -242,66 +268,75 @@ export function ConversationScreen(props: { + setInputHeight( + Math.max(44, Math.min(132, event.nativeEvent.contentSize.height)) + ) + } + style={[styles.input, Platform.OS === "web" ? webInputSizing : { height: inputHeight }]} selectionColor={colors.accent} /> - - setShowSettings(true)} - style={styles.modelButton} - > - - - {options?.agentId ?? "Agent"} - - {" "} - · {options?.model.split(":").slice(1).join(":") || "Choose model"} - - - - - - - - + + - - {!ready - ? "Syncing required before sending" - : running - ? "Running on your server" - : options?.thinkingLevel - ? `${options.thinkingLevel} thinking · Runs on your server` - : "Runs on your server"} - + + setShowSettings(true)} + style={({ pressed }) => [styles.modelButton, pressed && { opacity: 0.6 }]} + > + + + {settings?.agents.find((agent) => agent.id === options?.agentId)?.name ?? "Agent"} + + {" "} + ·{" "} + {options?.model + ? formatModelDisplayName(options.model.slice(options.model.indexOf(":") + 1)) + : "Choose model"} + + + + + {running && ( + + Working + + )} + {showSettings && settings && options && ( setShowSettings(false)} onSave={(value) => { - setOverrides(value); + props.onSelectionChange(value); setShowSettings(false); }} /> @@ -321,64 +356,87 @@ export function ConversationScreen(props: { const styles = StyleSheet.create({ header: { - minHeight: 68, - paddingHorizontal: 12, + minHeight: 56, + paddingHorizontal: 8, flexDirection: "row", alignItems: "center", - gap: 8, - borderBottomWidth: 1, + gap: 4, + borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, }, - title: { color: colors.bright, fontSize: 15, fontWeight: "600" }, + title: { color: colors.bright, fontSize: 17, lineHeight: 22, fontWeight: "600" }, messages: { - padding: 18, - paddingBottom: 28, + paddingHorizontal: spacing.xl, + paddingTop: 12, + paddingBottom: spacing.xl, width: "100%", - maxWidth: 820, + maxWidth: 760, alignSelf: "center", flexGrow: 1, }, - empty: { flex: 1, paddingVertical: 60, alignItems: "center", justifyContent: "center", gap: 12 }, - emptyTitle: { color: colors.bright, fontSize: 24, fontWeight: "500", letterSpacing: -0.6 }, + empty: { + flex: 1, + paddingVertical: 48, + paddingHorizontal: spacing.xl, + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + emptyTitle: { color: colors.bright, fontSize: 22, fontWeight: "600", letterSpacing: -0.4 }, composerWrap: { - paddingHorizontal: 12, + paddingHorizontal: spacing.lg, paddingTop: 8, - gap: 8, width: "100%", - maxWidth: 820, + maxWidth: 760, alignSelf: "center", + gap: 8, + backgroundColor: colors.background, }, composer: { - borderRadius: 14, - padding: 10, + flexDirection: "row", + alignItems: "flex-end", + borderRadius: radii.sheet, + padding: 4, backgroundColor: colors.panel, borderColor: colors.border, - borderWidth: 1, + borderWidth: StyleSheet.hairlineWidth, }, input: { + flex: 1, + minWidth: 0, color: colors.bright, - fontSize: 15, + fontSize: 16, lineHeight: 23, - minHeight: 64, - maxHeight: 160, + minHeight: 44, + maxHeight: 132, textAlignVertical: "top", - padding: 4, + paddingVertical: 10, + paddingHorizontal: 12, + }, + composerToolbar: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingBottom: 2, }, modelButton: { minHeight: 44, flexDirection: "row", alignItems: "center", gap: 6, - paddingHorizontal: 4, flexShrink: 1, + paddingHorizontal: 6, }, - send: { borderRadius: 9, backgroundColor: colors.elevated, marginLeft: 6 }, - status: { color: colors.dim, fontSize: 11, textAlign: "center", paddingBottom: 8 }, + modeDot: { width: 6, height: 6, borderRadius: 3 }, + modelLabel: { color: colors.text, fontSize: 13, fontWeight: "500", flexShrink: 1 }, + activity: { color: colors.muted, fontSize: 12, marginLeft: 8 }, + send: { borderRadius: 22, overflow: "hidden", backgroundColor: colors.elevated }, latest: { position: "absolute", - bottom: 190, - right: 24, + right: 20, backgroundColor: colors.elevated, - borderRadius: 24, + borderRadius: radii.sheet, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, }, }); diff --git a/packages/mobile/src/screens/ModelSettings.tsx b/packages/mobile/src/screens/ModelSettings.tsx index 285e967155b..ce97d29b8d0 100644 --- a/packages/mobile/src/screens/ModelSettings.tsx +++ b/packages/mobile/src/screens/ModelSettings.tsx @@ -43,75 +43,82 @@ export function ModelSettings(props: { const currentAgent = agents.find((agent) => agent.id === value.agentId); return ( setBrowsing(false) : undefined} onClose={props.onClose} footer={ - + !browsing && ( + + ) } > - - Agent - - {agents.map((agent) => ( - - - {currentAgent?.description ?? "Choose an agent for your next message."} - - - - Model - setBrowsing(!browsing)} - style={[layout.group, styles.modelRow]} - > - - - - {value.model ? modelName(value.model) : "Choose a model"} + {!browsing && ( + <> + + Agent + + {agents.map((agent) => ( + + + {currentAgent?.description ?? "Choose an agent for your next message."} - - {value.model.split(":")[0]} + + + Model + setBrowsing(!browsing)} + style={[layout.group, styles.modelRow]} + > + + + + {value.model ? modelName(value.model) : "Choose a model"} + + + {value.model.split(":")[0]} + + + + + + + Thinking + + + + Applies to your next message. Model capabilities are checked by your server. - - - - - Thinking - - - - Applies to your next message. Model capabilities are checked by your server. - - + + )} {browsing && ( setCustom(!custom)} + onPress={() => { + setBrowsing(false); + setCustom(!custom); + }} style={styles.disclosure} > diff --git a/packages/mobile/src/screens/Navigator.tsx b/packages/mobile/src/screens/Navigator.tsx index b70c1e0457a..e1b4481039d 100644 --- a/packages/mobile/src/screens/Navigator.tsx +++ b/packages/mobile/src/screens/Navigator.tsx @@ -1,18 +1,29 @@ import { useState } from "react"; -import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { + Check, + ChevronDown, + ChevronRight, Folder, GitBranch, MessageSquare, Plus, - RefreshCw, + Search, Settings, X, } from "lucide-react-native"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; import type { Projects } from "../useProjects"; -import { IconButton, Loading, Notice } from "../components/Controls"; -import { colors, layout } from "../theme"; +import { Button, IconButton, Loading, Notice } from "../components/Controls"; +import { colors, layout, radii, spacing, typography } from "../theme"; export function Navigator(props: { projects: Projects; @@ -24,9 +35,10 @@ export function Navigator(props: { onSelect: (workspace: FrontendWorkspaceMetadata) => void; onCreate: () => void; onSettings: () => void; - onClose?: () => void; + compact?: boolean; }) { const [collapsed, setCollapsed] = useState>(() => new Set()); + const [query, setQuery] = useState(""); const groups = new Map(); for (const [path, config] of props.projects) groups.set(path, { @@ -39,39 +51,94 @@ export function Navigator(props: { name: workspace.kind === "scratch" ? "Scratch chats" : workspace.projectName, workspaces: [], }; - group.workspaces.push(workspace); + if ( + `${workspace.title ?? ""} ${workspace.name} ${group.name}` + .toLowerCase() + .includes(query.trim().toLowerCase()) + ) + group.workspaces.push(workspace); groups.set(key, group); } + const visibleGroups = [...groups].filter( + ([, group]) => !query.trim() || group.workspaces.length > 0 + ); return ( - - + + xum. - - - {props.onClose && ( - - )} + + - + 0} + onRefresh={props.onRetry} + tintColor={colors.accent} + /> + } + > + + Workspaces + + {props.workspaces.length === 0 + ? "Your conversations, organized by project." + : `${props.workspaces.length} ${props.workspaces.length === 1 ? "conversation" : "conversations"}`} + + + + + + {query.length > 0 && ( + setQuery("")} /> + )} + {props.error && {props.error}} - {props.loading && } - {!props.loading && groups.size === 0 && ( - - A little room to think. - - Create a scratch chat, or add a project from Xum desktop to get started. + {props.loading && props.workspaces.length === 0 && } + {!props.loading && props.workspaces.length === 0 && !props.error && ( + + + Start a conversation + + Create a workspace in a project, or a scratch chat for a quick question. + )} - {[...groups].map(([key, group]) => ( - + {query.trim() && visibleGroups.length === 0 ? ( + + No matching workspaces + Try a project name, branch, or conversation title. + + ) : null} + {visibleGroups.map(([key, group]) => ( + setCollapsed((current) => { const next = new Set(current); @@ -80,100 +147,150 @@ export function Navigator(props: { return next; }) } - style={styles.group} + style={styles.groupHeader} > {key === "scratch" ? ( ) : ( )} - + {group.name} {group.workspaces.length} + {collapsed.has(key) && !query.trim() ? ( + + ) : ( + + )} - {!collapsed.has(key) && - group.workspaces - .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? "")) - .map((workspace) => ( - props.onSelect(workspace)} - style={({ pressed }) => [ - styles.workspace, - workspace.id === props.selectedId && styles.selected, - pressed && { opacity: 0.7 }, - ]} - > - - - - {workspace.title ?? workspace.name} - - - {workspace.name} - - - - ))} + {(!collapsed.has(key) || Boolean(query.trim())) && ( + + {group.workspaces.length === 0 ? ( + No conversations yet + ) : ( + group.workspaces + .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? "")) + .map((workspace, index) => ( + props.onSelect(workspace)} + style={({ pressed }) => [ + styles.workspace, + index > 0 && styles.separator, + (workspace.id === props.selectedId || pressed) && { + backgroundColor: colors.elevated, + }, + ]} + > + + {workspace.kind === "scratch" ? ( + + ) : ( + + )} + + + + {workspace.title ?? workspace.name} + + + {workspace.kind === "scratch" ? "Scratch chat" : workspace.name} + + + {workspace.id === props.selectedId ? ( + + ) : ( + + )} + + )) + )} + + )} ))} - - - Settings & connection - ); } const styles = StyleSheet.create({ - root: { - flex: 1, - backgroundColor: colors.panel, - borderRightColor: colors.border, - borderRightWidth: 1, - }, - top: { - minHeight: 68, - paddingHorizontal: 16, + sidebar: { backgroundColor: colors.background }, + toolbar: { + minHeight: 52, + paddingHorizontal: spacing.lg, flexDirection: "row", alignItems: "center", justifyContent: "space-between", - borderBottomWidth: 1, - borderBottomColor: colors.border, }, - brand: { color: colors.bright, fontWeight: "700", letterSpacing: -1, fontSize: 26 }, - group: { + brand: { color: colors.bright, fontWeight: "700", letterSpacing: -0.8, fontSize: 24 }, + content: { + paddingHorizontal: spacing.xl, + paddingTop: 8, + paddingBottom: 32, + gap: 20, + maxWidth: 760, + width: "100%", + alignSelf: "center", + }, + title: { + color: colors.bright, + fontWeight: "700", + letterSpacing: -0.8, + fontSize: 32, + lineHeight: 38, + }, + search: { + backgroundColor: colors.panel, + borderRadius: radii.control, + minHeight: 46, flexDirection: "row", + alignItems: "center", + paddingLeft: 14, + paddingRight: 4, gap: 8, + }, + searchInput: { + flex: 1, + minWidth: 0, + minHeight: 44, + fontSize: 16, + color: colors.bright, + paddingVertical: 10, + }, + groupHeader: { + flexDirection: "row", alignItems: "center", + gap: 8, minHeight: 44, - paddingHorizontal: 8, + paddingHorizontal: 4, }, + sectionTitle: { color: colors.muted, fontSize: 13, fontWeight: "600" }, + section: { borderRadius: radii.card, overflow: "hidden", backgroundColor: colors.panel }, workspace: { flexDirection: "row", alignItems: "center", - gap: 10, - padding: 12, - minHeight: 60, - borderRadius: 8, - marginBottom: 3, + gap: 12, + paddingHorizontal: 14, + paddingVertical: 15, + minHeight: 76, }, - selected: { backgroundColor: colors.elevated }, - workspaceTitle: { color: colors.text, fontSize: 14, lineHeight: 21 }, - footer: { - flexDirection: "row", + workspaceIcon: { + width: 34, + height: 34, + borderRadius: 10, alignItems: "center", - gap: 10, - minHeight: 60, - padding: 16, - borderTopWidth: 1, - borderTopColor: colors.border, + justifyContent: "center", + backgroundColor: colors.background, }, + workspaceTitle: { color: colors.bright, fontSize: 16, fontWeight: "500", lineHeight: 22 }, + separator: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border }, + empty: { paddingVertical: 24, gap: 14, alignItems: "center" }, }); diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 6f38cbea03d..748fcb194a9 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -125,7 +125,7 @@ test("workspace creation cannot be dismissed or submitted twice while the server expect(selected).toBe(workspace); }); -test("model search accepts friendly names and keeps the submit action available without clearing the selection", async () => { +test("model search accepts friendly names and returning preserves the selection", async () => { const data: SettingsData = { config: { agentAiDefaults: {}, defaultModel: "anthropic:claude-sonnet-4-5" }, providers: { anthropic: { isConfigured: true, isEnabled: true, apiKeySet: true } }, @@ -152,6 +152,7 @@ test("model search accepts friendly names and keeps the submit action available ); fireEvent.change(view.getByLabelText("Search models"), { target: { value: "not-a-model" } }); expect(view.queryByRole("button", { name: "anthropic:claude-sonnet-4-5" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Back" })); fireEvent.click(view.getByRole("button", { name: "Use settings" })); expect(saved?.model).toBe("anthropic:claude-sonnet-4-5"); expect(saved?.thinkingLevel).toBe("high"); diff --git a/packages/mobile/src/useProjects.ts b/packages/mobile/src/useProjects.ts index f8aed0bbc8e..35a00624bac 100644 --- a/packages/mobile/src/useProjects.ts +++ b/packages/mobile/src/useProjects.ts @@ -53,5 +53,16 @@ export function useProjects(client: MobileClient, signal: AbortSignal) { }); return () => controller.abort(); }, [client, signal, generation]); - return { projects, workspaces, loading, error, retry: () => setGeneration((value) => value + 1) }; + return { + projects, + workspaces, + loading, + error, + retry: () => setGeneration((value) => value + 1), + addWorkspace: (workspace: FrontendWorkspaceMetadata) => + setWorkspaces((current) => [ + ...current.filter((item) => item.id !== workspace.id), + workspace, + ]), + }; } diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json index 96865eaf48f..cd775b61de3 100644 --- a/packages/mobile/tsconfig.json +++ b/packages/mobile/tsconfig.json @@ -5,8 +5,19 @@ "noEmit": true, "types": ["react", "bun"], "moduleSuffixes": [".native", ""], - "paths": { "@/*": ["../../src/*"], "@shared/*": ["../../src/*"] } + "paths": { + "@/*": ["../../src/*"], + "@shared/*": ["../../src/*"] + } }, - "include": ["App.tsx", "index.ts", "src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts"], + "include": [ + "App.tsx", + "index.ts", + "src/**/*.ts", + "src/**/*.tsx", + "scripts/**/*.ts", + "e2e/**/*.ts", + "playwright.config.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 692afce01f7..ea6e432e950 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6986,7 +6986,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website.", "", - "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. The project/workspace navigator, bottom composer, and full-screen workspace panels follow Xum's mobile navigation. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", + "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation and model settings use sheets with pinned actions. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", "", "## Connect to a server", "", @@ -7008,7 +7008,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "XUM_MOBILE_ENDPOINT=http://127.0.0.1:3000 make mobile-web", "```", "", - "Open `http://127.0.0.1:8082`, then enter that configured **server endpoint** and its token. Metro runs on port 8081; use the proxy on 8082, not Metro's direct URL, for API access.", + "Open `http://127.0.0.1:8082` in a current Chromium browser, then enter that configured **server endpoint** and its token. Metro runs on port 8081; use the proxy on 8082, not Metro's direct URL, for API access. The web preview uses CSS content sizing for the composer; native builds use React Native's text measurement.", "", "The preview forwards to exactly one endpoint configured at startup. It checks the request Host and Origin before forwarding, strips preview cookies/forwarded identity, and preserves the upstream path prefix. It does not relax the production server's origin protections. Native builds connect directly and do not need this proxy.", "", @@ -7062,6 +7062,18 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "That test creates and removes a scratch workspace. It exercises real authentication, persistence, streaming and reconnect/replay; only the model response is deterministic.", "", + "With the production preview running against that same disposable server, run the full-app browser regressions from the repository root:", + "", + "```bash", + "# One-time browser installation", + "(cd packages/mobile && bun x playwright install chromium)", + "XUM_MOBILE_TEST_ENDPOINT=http://127.0.0.1:3000 \\", + "XUM_MOBILE_TEST_TOKEN=your-disposable-server-token \\", + "make mobile-test-web", + "```", + "", + "These tests pin 375px, 390px, and 1200px viewports, create and remove scratch chats, and check draft/model retention, keyboard focus, and reachable sheet actions. Set `XUM_MOBILE_TEST_WEB_URL` if the preview is not at `http://127.0.0.1:8082`.", + "", "For a browser walkthrough, use the production preview and a phone viewport around 375–390 pixels, then repeat at tablet/desktop width:", "", "1. Check invalid URL, wrong token, and successful connection.", From 43e839fc8b25f231e492bb89e05c763e3a60a076 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 17:20:18 +0000 Subject: [PATCH 08/84] =?UTF-8?q?=F0=9F=A4=96=20fix:=20explain=20empty=20m?= =?UTF-8?q?obile=20assistant=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a neutral fallback when an empty persisted assistant row has no interruption marker; preserve explicit interrupted and active-stream behavior. Cover these branches with native-web behavior tests. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$148.33`_ --- packages/mobile/src/components/Message.tsx | 13 ++++++++----- .../mobile/src/screens/formTestPlatform.ts | 4 ++++ packages/mobile/src/screens/forms.behavior.tsx | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index ab0ed9ac80c..cf1d8a1d4ba 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -47,13 +47,16 @@ export function Message(props: { ); } })} - {!user && - props.message.metadata?.partial && + {props.message.role === "assistant" && !props.streaming && - !props.message.metadata.error && ( + !props.message.metadata?.error && + (props.message.metadata?.partial || props.message.parts.length === 0) && ( - - Interrupted + {props.message.metadata?.partial && } + {/* Empty replay rows may lack an interruption marker; don't invent a stop reason. */} + + {props.message.metadata?.partial ? "Interrupted" : "No response received"} + )} diff --git a/packages/mobile/src/screens/formTestPlatform.ts b/packages/mobile/src/screens/formTestPlatform.ts index aaf6b131f2d..cdde9e41713 100644 --- a/packages/mobile/src/screens/formTestPlatform.ts +++ b/packages/mobile/src/screens/formTestPlatform.ts @@ -26,6 +26,10 @@ mock.module("lucide-react-native", () => "LogOut", "Server", "ShieldCheck", + "Brain", + "File", + "Pause", + "Wrench", ].map((name) => [name, icon]) ) ); diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 748fcb194a9..6919d9d774e 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -7,6 +7,7 @@ import type { TextInput } from "react-native"; import type { MobileClient } from "../api"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; import { Button, Field, Sheet } from "../components/Controls"; +import { Message } from "../components/Message"; import { CreateWorkspace } from "./CreateWorkspace"; import { ModelSettings } from "./ModelSettings"; import { SettingsScreen } from "./SettingsScreen"; @@ -23,6 +24,23 @@ const workspace: FrontendWorkspaceMetadata = { runtimeConfig: { type: "local" }, }; +test("empty assistant history is explained without mislabeling a live or completed response", () => { + const props = { canAnswer: false, onAnswer: async () => {} }; + const message = { id: "empty", role: "assistant" as const, parts: [] }; + const view = render(); + expect(view.queryByText("No response received")).toBeNull(); + view.rerender(); + expect(view.getByText("No response received")).toBeDefined(); + view.rerender(); + expect(view.getByText("Interrupted")).toBeDefined(); + expect(view.queryByText("No response received")).toBeNull(); + view.rerender( + + ); + expect(view.queryByText("No response received")).toBeNull(); + expect(view.queryByText("Interrupted")).toBeNull(); +}); + test("a field ref focuses the next native input", () => { const next = createRef(); const view = render( From 2f170dc959b9472a2696c1790574464eec1fb3cc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 17:42:08 +0000 Subject: [PATCH 09/84] =?UTF-8?q?=F0=9F=A4=96=20feat:=20refine=20mobile=20?= =?UTF-8?q?transcript=20and=20tool=20inspection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render a calmer native transcript with accessible message roles, hanging Markdown lists, readable literal code blocks, and transparent tool/reasoning action rows. Open bounded tool inspection sheets, retain inline question answers, and derive tool state only from real execution/result metadata. Validation: mobile TypeScript, targeted ESLint, formatting, 60 mobile source tests including 13 native-web interaction cases. Parent owns integrated reference screenshots and mobile browser verification. --- packages/mobile/src/components/Markdown.tsx | 171 ++++++++++--- packages/mobile/src/components/Message.tsx | 235 ++++++++++++------ .../mobile/src/screens/forms.behavior.tsx | 199 +++++++++++++++ 3 files changed, 486 insertions(+), 119 deletions(-) diff --git a/packages/mobile/src/components/Markdown.tsx b/packages/mobile/src/components/Markdown.tsx index 708a2de1917..e5ce56f23ff 100644 --- a/packages/mobile/src/components/Markdown.tsx +++ b/packages/mobile/src/components/Markdown.tsx @@ -1,18 +1,96 @@ import type { ReactNode } from "react"; import { ScrollView, StyleSheet, Text, View } from "react-native"; -import { colors, layout, mono } from "../theme"; +import { colors, layout, mono, radii, spacing, typography } from "../theme"; + +type Block = + | { type: "paragraph" | "heading"; text: string } + | { type: "code"; language: string; text: string } + | { type: "list"; items: Array<{ marker: string; text: string; indent: number }> }; + +const listItem = /^(\s*)([-+*]|\d+[.)])\s+(.+)$/; +const heading = /^ {0,3}#{1,6}\s+(.+)$/; +const fence = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)\r?$/; + +function blocks(text: string): Block[] { + const lines = text.split("\n"); + const result: Block[] = []; + let index = 0; + while (index < lines.length) { + const line = lines[index]; + if (!line.trim()) { + index++; + continue; + } + const opening = fence.exec(line); + if (opening) { + const start = ++index; + const closing = new RegExp(`^ {0,3}${opening[1][0]}{${opening[1].length},}\\s*$`); + while (index < lines.length && !closing.test(lines[index])) index++; + // Preserve code whitespace and unfinished streaming fences instead of reinterpreting + // their content as Markdown (or hiding it until the closing fence arrives). + const code = + lines.slice(start, index).join("\n") + (index < lines.length && index > start ? "\n" : ""); + result.push({ type: "code", language: opening[2].trim(), text: code }); + if (index < lines.length) index++; + continue; + } + const title = heading.exec(line); + if (title) { + result.push({ type: "heading", text: title[1] }); + index++; + continue; + } + if (listItem.test(line)) { + const items: Extract["items"] = []; + while (index < lines.length) { + const item = listItem.exec(lines[index]); + if (!item) break; + index++; + let content = item[3]; + while ( + index < lines.length && + /^\s+\S/.test(lines[index]) && + !listItem.test(lines[index]) && + !fence.test(lines[index]) && + !heading.test(lines[index]) + ) { + content += `\n${lines[index].trimStart()}`; + index++; + } + items.push({ + marker: /^\d/.test(item[2]) ? item[2] : "•", + text: content, + indent: item[1].length, + }); + } + result.push({ type: "list", items }); + continue; + } + const start = index++; + while ( + index < lines.length && + lines[index].trim() && + !heading.test(lines[index]) && + !fence.test(lines[index]) && + !listItem.test(lines[index]) + ) + index++; + result.push({ type: "paragraph", text: lines.slice(start, index).join("\n") }); + } + return result; +} function inline(text: string): ReactNode[] { - return text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g).map((part, index) => { - if (part.startsWith("`") && part.endsWith("`")) + return text.split(/(`[^`\n]+`|\*\*[^*\n]+\*\*)/g).map((part, index) => { + if (/^`[^`\n]+`$/.test(part)) return ( {part.slice(1, -1)} ); - if (part.startsWith("**") && part.endsWith("**")) + if (/^\*\*[^*\n]+\*\*$/.test(part)) return ( - + {part.slice(2, -2)} ); @@ -20,54 +98,69 @@ function inline(text: string): ReactNode[] { }); } -// Render untrusted model/repository text only through native Text, never HTML. +// Repo/model text is untrusted: all content remains escaped native Text, never HTML. export function Markdown(props: { text: string }) { - const blocks = props.text.split(/```([^\n]*)\n([\s\S]*?)(?:```|$)/g); return ( - - {blocks.map((block, index) => { - if (index % 3 === 1) return null; - if (index % 3 === 2) + + {blocks(props.text).map((block, index) => { + if (block.type === "code") return ( - {blocks[index - 1] || "CODE"} - + {block.language !== "" && {block.language}} + - {block.trimEnd()} + {block.text} ); - return block - .split(/\n\s*\n/) - .filter(Boolean) - .map((paragraph, line) => { - const heading = /^(#{1,6})\s+(.+)$/.exec(paragraph); - return ( - - {inline(heading ? heading[2] : paragraph.replace(/^[-*] /gm, "• "))} - - ); - }); + if (block.type === "list") + return ( + + {block.items.map((item, itemIndex) => ( + + {item.marker} + + {inline(item.text)} + + + ))} + + ); + return ( + + {inline(block.text)} + + ); })} ); } const styles = StyleSheet.create({ - inlineCode: { fontFamily: mono, backgroundColor: colors.elevated, color: colors.bright }, - code: { - backgroundColor: colors.panel, - borderRadius: 14, - padding: 16, - gap: 10, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.border, + content: { gap: spacing.lg, minWidth: 0 }, + inlineCode: { fontFamily: mono, backgroundColor: colors.user, color: colors.bright }, + heading: { ...typography.header, color: colors.bright, marginTop: spacing.sm }, + list: { gap: spacing.md }, + listItem: { flexDirection: "row", alignItems: "flex-start", gap: spacing.sm }, + marker: { minWidth: 20, textAlign: "right", color: colors.muted }, + itemText: { flex: 1, minWidth: 0 }, + code: { backgroundColor: colors.panel, borderRadius: radii.control, overflow: "hidden" }, + codeLanguage: { + ...typography.footnote, + color: colors.muted, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, }, - codeText: { fontFamily: mono, fontSize: 13, lineHeight: 21, color: colors.text }, - heading: { fontSize: 19, lineHeight: 27, fontWeight: "600", color: colors.bright }, + codeBody: { padding: spacing.lg }, + codeText: { ...typography.footnote, fontFamily: mono, lineHeight: 21, color: colors.text }, }); diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index cf1d8a1d4ba..b35870c2482 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -1,11 +1,10 @@ import { useState } from "react"; -import type { ReactNode } from "react"; -import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { Brain, ChevronDown, ChevronRight, File, Pause, Wrench } from "lucide-react-native"; import type { MuxMessage, MuxToolPart } from "../../../../src/common/types/message"; -import { Button, Field, Notice } from "./Controls"; +import { Button, Field, Notice, Sheet } from "./Controls"; import { Markdown } from "./Markdown"; -import { colors, layout, mono } from "../theme"; +import { colors, layout, mono, radii, spacing, typography } from "../theme"; export function Message(props: { message: MuxMessage; @@ -14,26 +13,27 @@ export function Message(props: { onAnswer: (toolCallId: string, answers: Record) => Promise; }) { const user = props.message.role === "user"; + const label = user + ? "Your message" + : props.message.role === "assistant" + ? "Assistant message" + : "System message"; return ( - - {!user && ( - {props.message.role === "assistant" ? "Xum" : "System"} - )} + + {props.message.role === "system" && System} {props.message.parts.map((part, index) => { switch (part.type) { case "text": return ; case "reasoning": - return ( - - - - ); + return ; case "dynamic-tool": return ( @@ -42,95 +42,169 @@ export function Message(props: { return ( - {part.filename ?? part.mediaType} · attachment + {part.filename ?? part.mediaType} · attachment ); } })} {props.message.role === "assistant" && !props.streaming && - !props.message.metadata?.error && - (props.message.metadata?.partial || props.message.parts.length === 0) && ( - - {props.message.metadata?.partial && } - {/* Empty replay rows may lack an interruption marker; don't invent a stop reason. */} - - {props.message.metadata?.partial ? "Interrupted" : "No response received"} - - - )} + (props.message.metadata?.error ? ( + {props.message.metadata.error} + ) : ( + (props.message.metadata?.partial || props.message.parts.length === 0) && ( + + {props.message.metadata?.partial && } + {/* Empty replay rows may lack an interruption marker; don't invent a stop reason. */} + + {props.message.metadata?.partial ? "Interrupted" : "No response received"} + + + ) + ))} ); } -function Disclosure(props: { label: string; reasoning?: boolean; children: ReactNode }) { +function Reasoning(props: { text: string; streaming: boolean }) { const [expanded, setExpanded] = useState(false); - const Icon = props.reasoning ? Brain : Wrench; return ( - + setExpanded(!expanded)} - style={styles.disclosureHeader} + style={styles.actionRow} > - - - {props.label} - + + Reasoning {expanded ? ( ) : ( )} - {expanded && {props.children}} + {expanded && ( + + {props.text ? ( + + ) : ( + + {props.streaming ? "Thinking…" : "No reasoning text available."} + + )} + + )} ); } +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function toolHint(input: unknown): string | undefined { + if (!record(input)) return; + for (const key of ["path", "file_path", "filePath", "command", "script"]) { + const value = input[key]; + if (typeof value === "string" && value.trim()) + return value.replace(/\s+/g, " ").trim().slice(0, 160); + } +} + +function toolStatus(part: MuxToolPart, streaming: boolean, interrupted: boolean): string { + if (part.state === "output-redacted") return part.failed ? "Failed" : "Redacted"; + if (part.state === "output-available") { + return record(part.output) && (part.output.success === false || part.output.error) + ? "Failed" + : "Done"; + } + if (!streaming) return interrupted ? "Interrupted" : "No result"; + if (part.toolName === "ask_user_question") return "Needs input"; + return part.executionStartedAt != null ? "Running" : "Pending"; +} + const MAX_TOOL_CHARACTERS = 24_000; -function printable(value: unknown): string { +function ToolValue(props: { label: string; value: unknown }) { + const text = + typeof props.value === "string" + ? props.value + : (JSON.stringify(props.value, null, 2) ?? "No output"); return ( - typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "No output") - ).slice(0, MAX_TOOL_CHARACTERS); + + {props.label} + + + {text.slice(0, MAX_TOOL_CHARACTERS)} + + + {text.length > MAX_TOOL_CHARACTERS && ( + + Showing the first {MAX_TOOL_CHARACTERS.toLocaleString()} characters. + + )} + + ); } function Tool(props: { part: MuxToolPart; + streaming: boolean; + interrupted: boolean; canAnswer: boolean; onAnswer: (toolCallId: string, answers: Record) => Promise; }) { + const [inspecting, setInspecting] = useState(false); const questions = props.part.toolName === "ask_user_question" && props.part.state === "input-available" ? questionTexts(props.part.input) : []; + const hint = toolHint(props.part.input); + const status = toolStatus(props.part, props.streaming, props.interrupted); return ( - - letter.toUpperCase())} · ${props.part.state === "input-available" ? "running" : props.part.state === "output-redacted" ? "redacted" : "done"}`} + + setInspecting(true)} + style={styles.actionRow} > - Input - - {printable(props.part.input)} + + + + {props.part.toolName} + {hint && {hint}} + + + + {status} - {props.part.state === "output-available" && ( - <> - Output - - {printable(props.part.output)} - - - )} - {(printable(props.part.input).length === MAX_TOOL_CHARACTERS || - (props.part.state === "output-available" && - printable(props.part.output).length === MAX_TOOL_CHARACTERS)) && ( - - Showing the first {MAX_TOOL_CHARACTERS.toLocaleString()} characters. + + + {inspecting && ( + setInspecting(false)}> + + {status} - )} - + + {props.part.state === "output-available" ? ( + + ) : props.part.state === "output-redacted" ? ( + The tool output is redacted. + ) : ( + + {props.streaming ? "Waiting for tool output…" : "No tool output was recorded."} + + )} + + )} {questions.length > 0 && ( { + const hostile = ''; + const view = render( + + ); + expect(view.getByRole("heading").textContent).toBe("Steps"); + expect(view.getAllByRole("list")).toHaveLength(2); + const items = view.getAllByRole("listitem"); + expect(items).toHaveLength(4); + expect(items[0].textContent).toContain(`1.Keep ${hostile}\nand this continuation`); + expect(items[1].textContent).toBe("2.Preserve a_b"); + expect(view.container.querySelector("img")).toBeNull(); + expect(view.getByText("After the list.")).toBeDefined(); +}); + +test("Markdown keeps partial code fences and code whitespace literal throughout streaming", () => { + const code = "- not a list\n \n"; + const view = render(); + const codeElement = view.getByText( + (_, element) => element?.children.length === 0 && element.textContent === code + ); + expect(codeElement.textContent).toBe(code); + expect(view.queryByRole("list")).toBeNull(); + expect(view.container.querySelector("script")).toBeNull(); + view.rerender(); + expect( + view.getByText((_, element) => element?.children.length === 0 && element.textContent === code) + ).toBeDefined(); + expect(view.getByText("Next paragraph")).toBeDefined(); + view.rerender(); + expect(view.container.textContent).toContain("Unfinished ` and ** delimiters stay literal.\n``"); +}); + +function toolMessage(part: MuxToolPart, metadata?: MuxMessage["metadata"]): MuxMessage { + return { id: "tool-message", role: "assistant", parts: [part], metadata }; +} + +test("a tool in a narrow transcript opens a sheet with literal output and closes without changing the message", () => { + const output = "\nactual command output"; + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "bash-call", + toolName: "bash", + input: { script: "git status --short" }, + state: "output-available", + output, + }; + const view = render( + + {}} /> + + ); + expect(view.getByRole("group", { name: "Assistant message" })).toBeDefined(); + expect( + view.queryByText( + (_, element) => element?.children.length === 0 && element.textContent === output + ) + ).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "bash: Done. git status --short" })); + expect( + view.getByText((_, element) => element?.children.length === 0 && element.textContent === output) + ).toBeDefined(); + expect(document.querySelector("img")).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Close" })); + expect( + view.queryByText( + (_, element) => element?.children.length === 0 && element.textContent === output + ) + ).toBeNull(); + expect(view.getByRole("button", { name: "bash: Done. git status --short" })).toBeDefined(); +}); + +test("tool headers distinguish execution, completion, failure, redaction, and interrupted replay", () => { + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "read-call", + toolName: "file_read", + input: { path: "src/app.ts", script: { not: "a string" } }, + state: "input-available", + }; + const renderMessage = (tool: MuxToolPart, streaming = false, partial = false) => ( + {}} + /> + ); + const view = render(renderMessage(part, true)); + expect(view.getByRole("button", { name: "file_read: Pending. src/app.ts" })).toBeDefined(); + view.rerender(renderMessage({ ...part, executionStartedAt: 0 }, true)); + expect(view.getByRole("button", { name: "file_read: Running. src/app.ts" })).toBeDefined(); + view.rerender(renderMessage(part)); + expect(view.getByRole("button", { name: "file_read: No result. src/app.ts" })).toBeDefined(); + view.rerender(renderMessage(part, false, true)); + expect(view.getByRole("button", { name: "file_read: Interrupted. src/app.ts" })).toBeDefined(); + view.rerender( + renderMessage({ + ...part, + state: "output-available", + output: { success: false, error: "denied" }, + }) + ); + expect(view.getByRole("button", { name: "file_read: Failed. src/app.ts" })).toBeDefined(); + view.rerender(renderMessage({ ...part, state: "output-redacted" })); + fireEvent.click(view.getByRole("button", { name: "file_read: Redacted. src/app.ts" })); + expect(view.queryByText("denied")).toBeNull(); +}); + +test("tool inspection caps large values but does not claim an exact-limit result is truncated", () => { + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "large", + toolName: "bash", + input: {}, + state: "output-available", + output: "x".repeat(24000), + }; + const view = render( + {}} /> + ); + fireEvent.click(view.getByRole("button", { name: "bash: Done" })); + expect(view.getByText("x".repeat(24000)).textContent).toHaveLength(24000); + expect(view.queryByText(/Showing the first/)).toBeNull(); + view.rerender( + {}} + /> + ); + expect(view.queryByText(/hidden suffix/)).toBeNull(); + expect(view.getByText(/Showing the first/)).toBeDefined(); +}); + +test("question answers remain inline and require complete input before submission", async () => { + const answers: Array> = []; + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "question", + toolName: "ask_user_question", + state: "input-available", + input: { questions: [{ question: "Which branch?" }, { question: "What should change?" }] }, + }; + const view = render( + { + answers.push(value); + }} + /> + ); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(answers).toHaveLength(0); + fireEvent.change(view.getByLabelText("Which branch?"), { target: { value: "main" } }); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(answers).toHaveLength(0); + fireEvent.change(view.getByLabelText("What should change?"), { + target: { value: "Keep the API stable" }, + }); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + }); + expect(answers).toEqual([ + { "Which branch?": "main", "What should change?": "Keep the API stable" }, + ]); +}); + +test("reasoning stays an inline disclosure and historical errors are not replaced by an empty-response hint", () => { + const message: MuxMessage = { + id: "reasoning", + role: "assistant", + parts: [{ type: "reasoning", text: "Consider as literal text." }], + }; + const props = { canAnswer: false, onAnswer: async () => {} }; + const view = render(); + expect(view.queryByText("Consider as literal text.")).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Reasoning" })); + expect(view.getByText("Consider as literal text.")).toBeDefined(); + expect(view.queryByRole("button", { name: "Close" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Reasoning" })); + expect(view.queryByText("Consider as literal text.")).toBeNull(); + view.rerender( + + ); + expect(view.getByRole("alert").textContent).toContain("Provider rejected the request"); + expect(view.queryByText("No response received")).toBeNull(); +}); From 5fc6af63180073edab8254a1698f951859eea313 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 17:53:41 +0000 Subject: [PATCH 10/84] =?UTF-8?q?=F0=9F=A4=96=20feat:=20refine=20mobile=20?= =?UTF-8?q?UI=20from=20Claude=20and=20Codex=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use quieter native surfaces and lightweight session navigation, center conversation context, integrate model and send controls into one composer, and progressively disclose thinking settings. Preserve transport, draft/model state, and native navigation; add browser coverage for focused settings. Validated mobile checks, pinned browser viewports, real-server integration, web and iOS Hermes exports, Expo compatibility, and root static checks. Captured reference provenance and real-server UI walkthroughs locally. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$248.65`_ --- docs/integrations/mobile-app.md | 2 +- packages/mobile/e2e/native-ux.spec.ts | 7 + packages/mobile/src/components/Controls.tsx | 10 +- packages/mobile/src/components/Message.tsx | 9 +- packages/mobile/src/screens/ConnectScreen.tsx | 4 +- .../mobile/src/screens/ConversationScreen.tsx | 132 +++++++++--------- packages/mobile/src/screens/ModelSettings.tsx | 61 +++++--- packages/mobile/src/screens/Navigator.tsx | 77 +++------- .../mobile/src/screens/forms.behavior.tsx | 18 +-- packages/mobile/src/theme.ts | 36 ++--- .../builtInSkillContent.generated.ts | 2 +- 11 files changed, 181 insertions(+), 177 deletions(-) diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index 1b17d154dad..747c0e70eed 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -5,7 +5,7 @@ description: Develop the React Native Xum companion and connect it to your serve The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website. -It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation and model settings use sheets with pinned actions. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. +It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation and model settings use sheets with pinned actions. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Expand Thinking in conversation settings to adjust reasoning effort. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. ## Connect to a server diff --git a/packages/mobile/e2e/native-ux.spec.ts b/packages/mobile/e2e/native-ux.spec.ts index b473b197c7b..07f67a55cb8 100644 --- a/packages/mobile/e2e/native-ux.spec.ts +++ b/packages/mobile/e2e/native-ux.spec.ts @@ -59,6 +59,13 @@ test("native stack preserves drafts and sheets keep their actions reachable", as await page.getByRole("button", { name: "Back", exact: true }).click(); await withinViewport(page, apply); await page.getByRole("button", { name: "Plan", exact: true }).click(); + const thinking = page.getByRole("button", { name: "Thinking effort", exact: true }); + await expect(page.getByRole("button", { name: "High", exact: true })).not.toBeVisible(); + await thinking.click(); + await page.getByRole("button", { name: "High", exact: true }).click(); + await thinking.click(); + await expect(thinking).toContainText("High"); + await withinViewport(page, apply); await apply.click(); await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveValue(draft); await expect( diff --git a/packages/mobile/src/components/Controls.tsx b/packages/mobile/src/components/Controls.tsx index 5a26c238468..834a04ff711 100644 --- a/packages/mobile/src/components/Controls.tsx +++ b/packages/mobile/src/components/Controls.tsx @@ -157,7 +157,11 @@ export function Header(props: { }) { return ( - {props.onBack && } + {props.onBack ? ( + + ) : ( + + )} {props.title} @@ -168,7 +172,7 @@ export function Header(props: { )} - {props.trailing} + {props.trailing ?? } ); } @@ -302,7 +306,7 @@ const styles = StyleSheet.create({ borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth, }, - headerTitle: { ...typography.header, color: colors.bright }, + headerTitle: { ...typography.header, color: colors.bright, textAlign: "center" }, webOverlay: { flex: 1, justifyContent: "flex-end", alignItems: "center" }, scrim: { ...StyleSheet.absoluteFill, backgroundColor: colors.scrim }, webSheet: { diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index b35870c2482..e7f016dbb7f 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -165,13 +165,16 @@ function Tool(props: { props.part.toolName === "ask_user_question" && props.part.state === "input-available" ? questionTexts(props.part.input) : []; + const name = props.part.toolName + .replaceAll("_", " ") + .replace(/^./, (letter) => letter.toUpperCase()); const hint = toolHint(props.part.input); const status = toolStatus(props.part, props.streaming, props.interrupted); return ( setInspecting(true)} style={styles.actionRow} @@ -179,7 +182,7 @@ function Tool(props: { - {props.part.toolName} + {name} {hint && {hint}} @@ -189,7 +192,7 @@ function Tool(props: { {inspecting && ( - setInspecting(false)}> + setInspecting(false)}> {status} diff --git a/packages/mobile/src/screens/ConnectScreen.tsx b/packages/mobile/src/screens/ConnectScreen.tsx index bd9f6e1849b..82b5ab7c7a0 100644 --- a/packages/mobile/src/screens/ConnectScreen.tsx +++ b/packages/mobile/src/screens/ConnectScreen.tsx @@ -107,7 +107,7 @@ export function ConnectScreen(props: { onConnect: (connection: Connection) => vo ) : ( <> - + {props.workspace.title ?? props.workspace.name} - - {props.workspace.kind === "scratch" ? "Scratch chat" : props.workspace.name} ·{" "} - {props.workspace.runtimeConfig.type} + + {props.workspace.kind === "scratch" + ? "Scratch chat" + : `${props.workspace.projectName} / ${props.workspace.name}`} - - - - - - setShowSettings(true)} - style={({ pressed }) => [styles.modelButton, pressed && { opacity: 0.6 }]} - > + + setShowSettings(true)} + style={({ pressed }) => [styles.modelButton, pressed && { opacity: 0.6 }]} + > + + + {settings?.agents.find((agent) => agent.id === options?.agentId)?.name ?? "Agent"} + + {" "} + ·{" "} + {options?.model + ? formatModelDisplayName(options.model.slice(options.model.indexOf(":") + 1)) + : "Choose model"} + + + + - - {settings?.agents.find((agent) => agent.id === options?.agentId)?.name ?? "Agent"} - - {" "} - ·{" "} - {options?.model - ? formatModelDisplayName(options.model.slice(options.model.indexOf(":") + 1)) - : "Choose model"} - - - - - {running && ( - - Working - - )} + > + + + {showSettings && settings && options && ( @@ -361,13 +357,12 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", gap: 4, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.border, }, - title: { color: colors.bright, fontSize: 17, lineHeight: 22, fontWeight: "600" }, + title: { ...typography.header, color: colors.bright, textAlign: "center", fontSize: 16 }, + subtitle: { ...typography.footnote, color: colors.muted, textAlign: "center", fontSize: 12 }, messages: { paddingHorizontal: spacing.xl, - paddingTop: 12, + paddingTop: 20, paddingBottom: spacing.xl, width: "100%", maxWidth: 760, @@ -382,10 +377,17 @@ const styles = StyleSheet.create({ justifyContent: "center", gap: 8, }, - emptyTitle: { color: colors.bright, fontSize: 22, fontWeight: "600", letterSpacing: -0.4 }, + emptyTitle: { + fontFamily, + color: colors.bright, + fontSize: 22, + fontWeight: "600", + letterSpacing: -0.4, + }, composerWrap: { - paddingHorizontal: spacing.lg, + paddingHorizontal: spacing.md, paddingTop: 8, + paddingBottom: 8, width: "100%", maxWidth: 760, alignSelf: "center", @@ -393,16 +395,14 @@ const styles = StyleSheet.create({ backgroundColor: colors.background, }, composer: { - flexDirection: "row", - alignItems: "flex-end", borderRadius: radii.sheet, - padding: 4, + padding: 6, backgroundColor: colors.panel, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth, }, input: { - flex: 1, + fontFamily, minWidth: 0, color: colors.bright, fontSize: 16, @@ -411,13 +411,14 @@ const styles = StyleSheet.create({ maxHeight: 132, textAlignVertical: "top", paddingVertical: 10, - paddingHorizontal: 12, + paddingHorizontal: 10, }, composerToolbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingBottom: 2, + paddingHorizontal: 4, + gap: 8, }, modelButton: { minHeight: 44, @@ -428,8 +429,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 6, }, modeDot: { width: 6, height: 6, borderRadius: 3 }, - modelLabel: { color: colors.text, fontSize: 13, fontWeight: "500", flexShrink: 1 }, - activity: { color: colors.muted, fontSize: 12, marginLeft: 8 }, + modelLabel: { fontFamily, color: colors.text, fontSize: 13, fontWeight: "500", flexShrink: 1 }, send: { borderRadius: 22, overflow: "hidden", backgroundColor: colors.elevated }, latest: { position: "absolute", diff --git a/packages/mobile/src/screens/ModelSettings.tsx b/packages/mobile/src/screens/ModelSettings.tsx index ce97d29b8d0..b89c49421bf 100644 --- a/packages/mobile/src/screens/ModelSettings.tsx +++ b/packages/mobile/src/screens/ModelSettings.tsx @@ -28,6 +28,7 @@ export function ModelSettings(props: { const [query, setQuery] = useState(""); const [browsing, setBrowsing] = useState(false); const [custom, setCustom] = useState(false); + const [showThinking, setShowThinking] = useState(false); const agents = props.data.agents.filter((agent) => agent.uiSelectable); const models = modelChoices(props.data, value.model).filter((model) => `${model} ${modelName(model)}`.toLowerCase().includes(query.trim().toLowerCase()) @@ -97,25 +98,47 @@ export function ModelSettings(props: { - Thinking - - + )} + {showThinking && ( + + Applies to your next message. Model capabilities are checked by your server. + + )} )} @@ -243,8 +266,8 @@ const styles = StyleSheet.create({ gap: spacing.xs, }, modelRow: { - minHeight: 64, - padding: spacing.lg, + minHeight: 56, + padding: spacing.md, flexDirection: "row", alignItems: "center", gap: spacing.md, diff --git a/packages/mobile/src/screens/Navigator.tsx b/packages/mobile/src/screens/Navigator.tsx index e1b4481039d..02d8cf6b759 100644 --- a/packages/mobile/src/screens/Navigator.tsx +++ b/packages/mobile/src/screens/Navigator.tsx @@ -9,11 +9,9 @@ import { View, } from "react-native"; import { - Check, ChevronDown, ChevronRight, Folder, - GitBranch, MessageSquare, Plus, Search, @@ -23,7 +21,7 @@ import { import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; import type { Projects } from "../useProjects"; import { Button, IconButton, Loading, Notice } from "../components/Controls"; -import { colors, layout, radii, spacing, typography } from "../theme"; +import { colors, fontFamily, layout, radii, spacing, typography } from "../theme"; export function Navigator(props: { projects: Projects; @@ -65,8 +63,8 @@ export function Navigator(props: { return ( - - xum. + + {props.compact ? "Xum" : "Workspaces"} @@ -90,14 +88,6 @@ export function Navigator(props: { /> } > - - Workspaces - - {props.workspaces.length === 0 - ? "Your conversations, organized by project." - : `${props.workspaces.length} ${props.workspaces.length === 1 ? "conversation" : "conversations"}`} - - (b.createdAt ?? "").localeCompare(a.createdAt ?? "")) - .map((workspace, index) => ( + .map((workspace) => ( props.onSelect(workspace)} style={({ pressed }) => [ styles.workspace, - index > 0 && styles.separator, (workspace.id === props.selectedId || pressed) && { backgroundColor: colors.elevated, }, ]} > - - {workspace.kind === "scratch" ? ( - - ) : ( - - )} - {workspace.title ?? workspace.name} @@ -204,11 +183,6 @@ export function Navigator(props: { {workspace.kind === "scratch" ? "Scratch chat" : workspace.name} - {workspace.id === props.selectedId ? ( - - ) : ( - - )} )) )} @@ -224,29 +198,22 @@ export function Navigator(props: { const styles = StyleSheet.create({ sidebar: { backgroundColor: colors.background }, toolbar: { - minHeight: 52, + minHeight: 60, paddingHorizontal: spacing.lg, flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, - brand: { color: colors.bright, fontWeight: "700", letterSpacing: -0.8, fontSize: 24 }, content: { - paddingHorizontal: spacing.xl, + paddingHorizontal: spacing.lg, paddingTop: 8, paddingBottom: 32, - gap: 20, + gap: 16, maxWidth: 760, width: "100%", alignSelf: "center", }, - title: { - color: colors.bright, - fontWeight: "700", - letterSpacing: -0.8, - fontSize: 32, - lineHeight: 38, - }, + title: { ...typography.header, color: colors.bright, fontSize: 20, letterSpacing: -0.4 }, search: { backgroundColor: colors.panel, borderRadius: radii.control, @@ -261,7 +228,7 @@ const styles = StyleSheet.create({ flex: 1, minWidth: 0, minHeight: 44, - fontSize: 16, + ...typography.body, color: colors.bright, paddingVertical: 10, }, @@ -272,25 +239,23 @@ const styles = StyleSheet.create({ minHeight: 44, paddingHorizontal: 4, }, - sectionTitle: { color: colors.muted, fontSize: 13, fontWeight: "600" }, - section: { borderRadius: radii.card, overflow: "hidden", backgroundColor: colors.panel }, + sectionTitle: { ...typography.footnote, color: colors.muted, fontWeight: "500" }, + section: { gap: 2 }, workspace: { flexDirection: "row", alignItems: "center", gap: 12, - paddingHorizontal: 14, - paddingVertical: 15, - minHeight: 76, + paddingHorizontal: 12, + paddingVertical: 10, + minHeight: 60, + borderRadius: radii.control, }, - workspaceIcon: { - width: 34, - height: 34, - borderRadius: 10, - alignItems: "center", - justifyContent: "center", - backgroundColor: colors.background, + workspaceTitle: { + fontFamily, + color: colors.bright, + fontSize: 16, + fontWeight: "500", + lineHeight: 22, }, - workspaceTitle: { color: colors.bright, fontSize: 16, fontWeight: "500", lineHeight: 22 }, - separator: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border }, empty: { paddingVertical: 24, gap: 14, alignItems: "center" }, }); diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 5e3d25d2866..c2a86248a1b 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -261,7 +261,7 @@ test("a tool in a narrow transcript opens a sheet with literal output and closes (_, element) => element?.children.length === 0 && element.textContent === output ) ).toBeNull(); - fireEvent.click(view.getByRole("button", { name: "bash: Done. git status --short" })); + fireEvent.click(view.getByRole("button", { name: "Bash: Done. git status --short" })); expect( view.getByText((_, element) => element?.children.length === 0 && element.textContent === output) ).toBeDefined(); @@ -272,7 +272,7 @@ test("a tool in a narrow transcript opens a sheet with literal output and closes (_, element) => element?.children.length === 0 && element.textContent === output ) ).toBeNull(); - expect(view.getByRole("button", { name: "bash: Done. git status --short" })).toBeDefined(); + expect(view.getByRole("button", { name: "Bash: Done. git status --short" })).toBeDefined(); }); test("tool headers distinguish execution, completion, failure, redaction, and interrupted replay", () => { @@ -292,13 +292,13 @@ test("tool headers distinguish execution, completion, failure, redaction, and in /> ); const view = render(renderMessage(part, true)); - expect(view.getByRole("button", { name: "file_read: Pending. src/app.ts" })).toBeDefined(); + expect(view.getByRole("button", { name: "File read: Pending. src/app.ts" })).toBeDefined(); view.rerender(renderMessage({ ...part, executionStartedAt: 0 }, true)); - expect(view.getByRole("button", { name: "file_read: Running. src/app.ts" })).toBeDefined(); + expect(view.getByRole("button", { name: "File read: Running. src/app.ts" })).toBeDefined(); view.rerender(renderMessage(part)); - expect(view.getByRole("button", { name: "file_read: No result. src/app.ts" })).toBeDefined(); + expect(view.getByRole("button", { name: "File read: No result. src/app.ts" })).toBeDefined(); view.rerender(renderMessage(part, false, true)); - expect(view.getByRole("button", { name: "file_read: Interrupted. src/app.ts" })).toBeDefined(); + expect(view.getByRole("button", { name: "File read: Interrupted. src/app.ts" })).toBeDefined(); view.rerender( renderMessage({ ...part, @@ -306,9 +306,9 @@ test("tool headers distinguish execution, completion, failure, redaction, and in output: { success: false, error: "denied" }, }) ); - expect(view.getByRole("button", { name: "file_read: Failed. src/app.ts" })).toBeDefined(); + expect(view.getByRole("button", { name: "File read: Failed. src/app.ts" })).toBeDefined(); view.rerender(renderMessage({ ...part, state: "output-redacted" })); - fireEvent.click(view.getByRole("button", { name: "file_read: Redacted. src/app.ts" })); + fireEvent.click(view.getByRole("button", { name: "File read: Redacted. src/app.ts" })); expect(view.queryByText("denied")).toBeNull(); }); @@ -324,7 +324,7 @@ test("tool inspection caps large values but does not claim an exact-limit result const view = render( {}} /> ); - fireEvent.click(view.getByRole("button", { name: "bash: Done" })); + fireEvent.click(view.getByRole("button", { name: "Bash: Done" })); expect(view.getByText("x".repeat(24000)).textContent).toHaveLength(24000); expect(view.queryByText(/Showing the first/)).toBeNull(); view.rerender( diff --git a/packages/mobile/src/theme.ts b/packages/mobile/src/theme.ts index c5a3acb88ea..65f91eb9dee 100644 --- a/packages/mobile/src/theme.ts +++ b/packages/mobile/src/theme.ts @@ -1,15 +1,15 @@ import { Platform, StyleSheet } from "react-native"; -// Native equivalents of the shared dark surface/content tokens in globals.css. +// Keep Xum's mode accents, with quieter surfaces so the conversation—not cards—owns attention. export const colors = { - background: "hsl(240, 10%, 4%)", - panel: "hsl(240, 6%, 10%)", - elevated: "hsl(240, 4%, 16%)", - border: "#262626", - text: "hsl(0, 0%, 83%)", - bright: "hsl(0, 0%, 100%)", - muted: "hsl(240, 5%, 65%)", - dim: "hsl(240, 5%, 34%)", + background: "hsl(240, 3%, 11%)", + panel: "hsl(240, 3%, 15%)", + elevated: "hsl(240, 3%, 19%)", + border: "hsl(240, 3%, 23%)", + text: "hsl(0, 0%, 90%)", + bright: "hsl(0, 0%, 97%)", + muted: "hsl(240, 3%, 64%)", + dim: "hsl(240, 3%, 45%)", accent: "hsl(268.56, 90%, 68%)", accentSurface: "hsla(268.56, 90%, 68%, 0.12)", plan: "hsl(210, 70%, 68%)", @@ -19,17 +19,19 @@ export const colors = { warningSurface: "hsla(38, 80%, 65%, 0.10)", success: "hsl(142, 76%, 46%)", user: "hsla(0, 0%, 100%, 0.06)", - scrim: "hsla(240, 10%, 4%, 0.72)", + scrim: "hsla(240, 3%, 3%, 0.58)", }; export const spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24, xxxl: 32 }; -export const radii = { control: 14, card: 18, sheet: 24, pill: 999 }; +export const radii = { control: 12, card: 16, sheet: 24, pill: 999 }; +// TextInput does not inherit Text typography on web; share the native system face explicitly. +export const fontFamily = Platform.OS === "android" ? "sans-serif" : "System"; export const typography = StyleSheet.create({ - title: { fontSize: 24, lineHeight: 30, fontWeight: "600", letterSpacing: -0.5 }, - header: { fontSize: 17, lineHeight: 22, fontWeight: "600" }, - body: { fontSize: 17, lineHeight: 24 }, - secondary: { fontSize: 15, lineHeight: 22 }, - footnote: { fontSize: 13, lineHeight: 18 }, + title: { fontFamily, fontSize: 24, lineHeight: 30, fontWeight: "600", letterSpacing: -0.5 }, + header: { fontFamily, fontSize: 17, lineHeight: 22, fontWeight: "600" }, + body: { fontFamily, fontSize: 16, lineHeight: 25 }, + secondary: { fontFamily, fontSize: 15, lineHeight: 22 }, + footnote: { fontFamily, fontSize: 13, lineHeight: 18 }, }); export const mono = Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" }); export const layout = StyleSheet.create({ @@ -41,7 +43,7 @@ export const layout = StyleSheet.create({ label: { ...typography.footnote, color: colors.muted, fontWeight: "600" }, content: { padding: spacing.xl, - gap: spacing.xxl, + gap: spacing.xl, width: "100%", maxWidth: 760, alignSelf: "center", diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index ea6e432e950..17772054a2b 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6986,7 +6986,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website.", "", - "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation and model settings use sheets with pinned actions. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", + "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation and model settings use sheets with pinned actions. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Expand Thinking in conversation settings to adjust reasoning effort. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", "", "## Connect to a server", "", From cb1551164dd97e9a359d8de77520038d17161635 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 21:44:38 +0000 Subject: [PATCH 11/84] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20mobile=20log?= =?UTF-8?q?in=20copy=20native-facing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use platform-neutral authentication copy in the native app and web development preview. Keep browser-only storage limitations in developer documentation; credential handling is unchanged. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$286.86`_ --- packages/mobile/src/screens/ConnectScreen.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/mobile/src/screens/ConnectScreen.tsx b/packages/mobile/src/screens/ConnectScreen.tsx index 82b5ab7c7a0..fd280977f63 100644 --- a/packages/mobile/src/screens/ConnectScreen.tsx +++ b/packages/mobile/src/screens/ConnectScreen.tsx @@ -161,12 +161,11 @@ export function ConnectScreen(props: { onConnect: (connection: Connection) => vo )} + {/* The web build previews native UX; storage differences belong in developer docs. */} - {Platform.OS === "web" - ? "Your token stays in this tab. It is never saved in browser storage." - : "Your connection is saved securely on this device."} + Your token authenticates this app with your Xum server. From c6773d5e9502c235be725ca60aadbcd2b4114380 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 22:11:16 +0000 Subject: [PATCH 12/84] =?UTF-8?q?=F0=9F=A4=96=20feat:=20use=20focused=20na?= =?UTF-8?q?tive=20mobile=20pickers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the combined conversation settings form with model, mode, and effort picker sheets inspired by the supplied native references. Apply list choices immediately, preserve model/effort when switching mode, and require confirmation only for custom model text. Verify selection behavior, draft retention, narrow/wide layouts, web and iOS exports, and real-server replay. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$347.29`_ --- docs/integrations/mobile-app.md | 2 +- packages/mobile/e2e/native-ux.spec.ts | 43 +- packages/mobile/src/components/Controls.tsx | 97 ++++- .../mobile/src/screens/ConversationScreen.tsx | 78 ++-- packages/mobile/src/screens/ModelSettings.tsx | 410 +++++++++--------- .../mobile/src/screens/formTestPlatform.ts | 3 + .../mobile/src/screens/forms.behavior.tsx | 159 +++++-- packages/mobile/src/theme.ts | 2 + .../builtInSkillContent.generated.ts | 2 +- 9 files changed, 497 insertions(+), 299 deletions(-) diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index 747c0e70eed..17afa16177a 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -5,7 +5,7 @@ description: Develop the React Native Xum companion and connect it to your serve The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website. -It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation and model settings use sheets with pinned actions. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Expand Thinking in conversation settings to adjust reasoning effort. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. +It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Use Effort or More models in the model picker for additional choices. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. ## Connect to a server diff --git a/packages/mobile/e2e/native-ux.spec.ts b/packages/mobile/e2e/native-ux.spec.ts index 07f67a55cb8..f20b9103a36 100644 --- a/packages/mobile/e2e/native-ux.spec.ts +++ b/packages/mobile/e2e/native-ux.spec.ts @@ -50,32 +50,33 @@ test("native stack preserves drafts and sheets keep their actions reachable", as await page.getByRole("button", { name: title, exact: true }).click(); await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveValue(draft); - await page.getByRole("button", { name: "Choose model, agent, and thinking" }).click(); - const apply = page.getByRole("button", { name: "Use settings", exact: true }); - await withinViewport(page, apply); - await page.getByRole("button", { name: "Choose model", exact: true }).click(); - await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible(); + const chooseModel = page.getByRole("button", { name: "Choose model", exact: true }); + await chooseModel.click(); + const more = page.getByRole("button", { name: "More models", exact: true }); + await withinViewport(page, more); + await more.click(); await page.getByRole("textbox", { name: "Search models" }).fill("no-match-model-query"); await page.getByRole("button", { name: "Back", exact: true }).click(); - await withinViewport(page, apply); - await page.getByRole("button", { name: "Plan", exact: true }).click(); - const thinking = page.getByRole("button", { name: "Thinking effort", exact: true }); - await expect(page.getByRole("button", { name: "High", exact: true })).not.toBeVisible(); - await thinking.click(); - await page.getByRole("button", { name: "High", exact: true }).click(); - await thinking.click(); - await expect(thinking).toContainText("High"); - await withinViewport(page, apply); - await apply.click(); + await page.getByRole("button", { name: /^Effort/ }).click(); + await page.getByRole("radio", { name: "High", exact: true }).click(); + await expect(page.getByRole("button", { name: /^Effort/ })).toContainText("High"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + const modelBeforeModeChange = await chooseModel.innerText(); + await page.getByRole("button", { name: "Choose mode", exact: true }).click(); + await page.getByRole("radio", { name: /^Plan/ }).click(); + await expect(chooseModel).toHaveText(modelBeforeModeChange); await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveValue(draft); - await expect( - page.getByRole("button", { name: "Choose model, agent, and thinking" }) - ).toContainText("Plan"); + await expect(page.getByRole("button", { name: "Choose mode", exact: true })).toContainText( + "Plan" + ); await page.getByRole("button", { name: "Back to workspaces", exact: true }).click(); await page.getByRole("button", { name: title, exact: true }).click(); - await expect( - page.getByRole("button", { name: "Choose model, agent, and thinking" }) - ).toContainText("Plan"); + await expect(page.getByRole("button", { name: "Choose mode", exact: true })).toContainText( + "Plan" + ); + await chooseModel.click(); + await expect(page.getByRole("button", { name: /^Effort/ })).toContainText("High"); + await page.getByRole("button", { name: "Close", exact: true }).click(); await message.fill(""); const viewport = page.viewportSize()!; diff --git a/packages/mobile/src/components/Controls.tsx b/packages/mobile/src/components/Controls.tsx index 834a04ff711..2afa8759716 100644 --- a/packages/mobile/src/components/Controls.tsx +++ b/packages/mobile/src/components/Controls.tsx @@ -182,6 +182,8 @@ export function Sheet(props: { children: ReactNode; onClose: () => void; footer?: ReactNode; + variant?: "picker"; + action?: ReactNode; dismissDisabled?: boolean; onBack?: () => void; }) { @@ -189,6 +191,7 @@ export function Sheet(props: { if (!props.dismissDisabled) props.onClose(); } const web = Platform.OS === "web"; + const picker = props.variant === "picker"; return ( - + {web && ( )} -
- } - /> + {picker ? ( + <> + {/* Native presentation owns swipe dismissal; the web preview shows its visual cue only. */} + + + + + + + {props.title} + + {props.action} + + + ) : ( +
+ } + /> + )} {props.children} @@ -318,6 +346,45 @@ const styles = StyleSheet.create({ borderTopRightRadius: radii.sheet, overflow: "hidden", }, + pickerOverlay: { paddingHorizontal: spacing.sm, paddingBottom: spacing.sm }, + pickerSheet: { + backgroundColor: colors.sheet, + borderTopLeftRadius: 32, + borderTopRightRadius: 32, + borderBottomLeftRadius: 32, + borderBottomRightRadius: 32, + maxWidth: 520, + }, + grabber: { + width: 32, + height: 4, + borderRadius: radii.pill, + backgroundColor: colors.dim, + alignSelf: "center", + marginTop: spacing.sm, + }, + pickerHeader: { + minHeight: 72, + paddingHorizontal: spacing.lg, + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + }, + pickerClose: { + width: 44, + height: 44, + borderRadius: radii.pill, + backgroundColor: colors.panel, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + pickerTitle: { ...typography.header, color: colors.bright, flex: 1, textAlign: "center" }, + pickerContent: { + paddingTop: spacing.sm, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xl, + gap: spacing.lg, + }, sheetContent: { flexGrow: 1, flexShrink: 1 }, footer: { borderTopWidth: StyleSheet.hairlineWidth, diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index dc0cca8a5c0..df28a709b24 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -27,9 +27,8 @@ import { useConversation } from "../useConversation"; import { linkedAbortController } from "../useConnection"; import { resolveSettings } from "../settings"; import type { ChatSettings } from "../settings"; -import { ModelSettings } from "./ModelSettings"; +import { ModelSettings, modelName } from "./ModelSettings"; import { colors, fontFamily, layout, radii, spacing, typography } from "../theme"; -import { formatModelDisplayName } from "../../../../src/common/utils/ai/modelDisplay"; import { DEFAULT_THINKING_LEVEL } from "../../../../src/common/types/thinking"; // RN Web reports scrollHeight, which cannot shrink a fixed-height textarea and @@ -59,7 +58,7 @@ export function ConversationScreen(props: { const [inputHeight, setInputHeight] = useState(44); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); - const [showSettings, setShowSettings] = useState(false); + const [showSettings, setShowSettings] = useState<"model" | "agent" | null>(null); const [atBottom, setAtBottom] = useState(true); const [composerHeight, setComposerHeight] = useState(100); const list = useRef>(null); @@ -288,32 +287,44 @@ export function ConversationScreen(props: { selectionColor={colors.accent} /> - setShowSettings(true)} - style={({ pressed }) => [styles.modelButton, pressed && { opacity: 0.6 }]} - > - + setShowSettings("agent")} + style={({ pressed }) => [ + styles.modelButton, + { maxWidth: "45%" }, + pressed && { opacity: 0.6 }, ]} - /> - - {settings?.agents.find((agent) => agent.id === options?.agentId)?.name ?? "Agent"} - - {" "} - ·{" "} - {options?.model - ? formatModelDisplayName(options.model.slice(options.model.indexOf(":") + 1)) - : "Choose model"} + > + + + {settings?.agents.find((agent) => agent.id === options?.agentId)?.name ?? "Mode"} - - - + + + setShowSettings("model")} + style={({ pressed }) => [styles.modelButton, pressed && { opacity: 0.6 }]} + > + + {options?.model ? modelName(options.model) : "Model"} + + + + {showSettings && settings && options && ( setShowSettings(false)} - onSave={(value) => { - props.onSelectionChange(value); - setShowSettings(false); - }} + onClose={() => setShowSettings(null)} + onChange={props.onSelectionChange} /> )} @@ -420,13 +429,16 @@ const styles = StyleSheet.create({ paddingHorizontal: 4, gap: 8, }, + pickers: { flex: 1, minWidth: 0, flexDirection: "row", gap: 6, alignItems: "center" }, modelButton: { minHeight: 44, flexDirection: "row", alignItems: "center", gap: 6, flexShrink: 1, - paddingHorizontal: 6, + paddingHorizontal: 10, + backgroundColor: colors.elevated, + borderRadius: radii.pill, }, modeDot: { width: 6, height: 6, borderRadius: 3 }, modelLabel: { fontFamily, color: colors.text, fontSize: 13, fontWeight: "500", flexShrink: 1 }, diff --git a/packages/mobile/src/screens/ModelSettings.tsx b/packages/mobile/src/screens/ModelSettings.tsx index b89c49421bf..ed599d7fdc8 100644 --- a/packages/mobile/src/screens/ModelSettings.tsx +++ b/packages/mobile/src/screens/ModelSettings.tsx @@ -1,14 +1,33 @@ import { useState } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; -import { Check, ChevronDown, ChevronRight, Cpu } from "lucide-react-native"; +import { Bot, Check, ChevronRight, ClipboardList, Code2 } from "lucide-react-native"; +import type { LucideIcon } from "lucide-react-native"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; import { formatModelDisplayName } from "../../../../src/common/utils/ai/modelDisplay"; -import { Button, Field, Sheet } from "../components/Controls"; +import { Field, Sheet } from "../components/Controls"; import { modelChoices, resolveSettings, thinkingLevels } from "../settings"; import type { ChatSettings, SettingsData } from "../settings"; +import type { ThinkingLevel } from "../../../../src/common/types/thinking"; import { colors, layout, radii, spacing, typography } from "../theme"; -function modelName(id: string) { +type Page = "model" | "catalog" | "effort" | "agent" | "custom"; +const titles: Record = { + model: "Select model", + catalog: "More models", + effort: "Effort", + agent: "Select mode", + custom: "Custom model", +}; +const effortLabels: Record = { + off: "Off", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra high", + max: "Maximum", +}; + +export function modelName(id: string) { return formatModelDisplayName( id .slice(id.indexOf(":") + 1) @@ -18,132 +37,103 @@ function modelName(id: string) { } export function ModelSettings(props: { + initialPage: "model" | "agent"; value: ChatSettings; data: SettingsData; workspace: FrontendWorkspaceMetadata; - onSave: (value: ChatSettings) => void; + onChange: (value: ChatSettings) => void; onClose: () => void; }) { - const [value, setValue] = useState(props.value); + const [page, setPage] = useState(props.initialPage); const [query, setQuery] = useState(""); - const [browsing, setBrowsing] = useState(false); - const [custom, setCustom] = useState(false); - const [showThinking, setShowThinking] = useState(false); - const agents = props.data.agents.filter((agent) => agent.uiSelectable); - const models = modelChoices(props.data, value.model).filter((model) => + const [customModel, setCustomModel] = useState(props.value.model); + const models = modelChoices(props.data, props.value.model); + const currentProvider = props.value.model.split(":")[0]; + // Start with the current choice and its provider, not invented recommendations or capability claims. + const featured = [ + ...models.filter((model) => model.split(":")[0] === currentProvider), + ...models.filter((model) => model.split(":")[0] !== currentProvider), + ].slice(0, 4); + const filtered = models.filter((model) => `${model} ${modelName(model)}`.toLowerCase().includes(query.trim().toLowerCase()) ); const groups = new Map(); - for (const model of models) { + for (const model of filtered) { const provider = model.split(":")[0]; - const group = groups.get(provider) ?? []; - group.push(model); - groups.set(provider, group); + groups.set(provider, [...(groups.get(provider) ?? []), model]); + } + function providerName(provider: string) { + return props.data.providers[provider]?.displayName ?? provider; + } + function selectModel(model: string) { + props.onChange({ ...props.value, model }); + props.onClose(); } - const validModel = /^\S+:\S+$/.test(value.model.trim()); - const currentAgent = agents.find((agent) => agent.id === value.agentId); + function modelRow(model: string, index: number) { + return ( + 0} + onPress={() => selectModel(model)} + /> + ); + } + const validCustom = /^\S+:\S+$/.test(customModel.trim()); return ( setBrowsing(false) : undefined} + variant="picker" + title={titles[page]} onClose={props.onClose} - footer={ - !browsing && ( - + + Done + + ) } > - {!browsing && ( + {page === "model" && ( <> - - Agent - - {agents.map((agent) => ( - - - {currentAgent?.description ?? "Choose an agent for your next message."} - - - - Model - setBrowsing(!browsing)} - style={[layout.group, styles.modelRow]} - > - - - - {value.model ? modelName(value.model) : "Choose a model"} - - - {value.model.split(":")[0]} - - - - + {featured.map(modelRow)} + + setPage("effort")} + disclosure + /> - - setShowThinking(!showThinking)} - style={[layout.group, styles.modelRow]} - > - Thinking - - {value.thinkingLevel - ? value.thinkingLevel[0].toUpperCase() + value.thinkingLevel.slice(1) - : "Default"} - - {showThinking ? ( - - ) : ( - - )} - - {showThinking && ( - - - )} - {showThinking && ( - - Applies to your next message. Model capabilities are checked by your server. - - )} + + setPage("catalog")} disclosure /> )} - {browsing && ( - + {page === "catalog" && ( + <> - {models.length === 0 ? ( - - No models match your search. Try another name or enter a custom model ID below. - - ) : ( - - {models.length} {models.length === 1 ? "model" : "models"} - - )} {[...groups].map(([provider, choices]) => ( - - {props.data.providers[provider]?.displayName ?? - provider[0].toUpperCase() + provider.slice(1)} - - - {choices.map((model, index) => ( - { - setValue({ ...value, model }); - setBrowsing(false); - setCustom(false); - }} - style={[styles.modelRow, index > 0 && styles.separator]} - > - - {modelName(model)} - - {model.slice(model.indexOf(":") + 1)} - - - {model === value.model && } - - ))} - + {providerName(provider)} + {choices.map(modelRow)} ))} + {filtered.length === 0 && No matching models.} + + setPage("custom")} disclosure /> + + + )} + {page === "effort" && ( + <> + + { + props.onChange({ ...props.value, thinkingLevel: undefined }); + setPage("model"); + }} + /> + {thinkingLevels.map((level) => ( + { + props.onChange({ ...props.value, thinkingLevel: level }); + setPage("model"); + }} + /> + ))} + + + Available effort levels depend on the model. Your server applies its capabilities. + + + )} + {page === "agent" && ( + + {props.data.agents + .filter((agent) => agent.uiSelectable) + .map((agent, index) => ( + 0} + icon={agent.id === "exec" ? Code2 : agent.id === "plan" ? ClipboardList : Bot} + selected={agent.id === props.value.agentId} + onPress={() => { + // Mode and model are separate controls: choosing a mode must not replace an explicit model/effort. + if (agent.id !== props.value.agentId) + props.onChange( + props.value.model + ? { ...props.value, agentId: agent.id } + : resolveSettings(props.workspace, props.data, agent.id) + ); + props.onClose(); + }} + /> + ))} )} - - { - setBrowsing(false); - setCustom(!custom); - }} - style={styles.disclosure} - > - - Use a custom model ID + {page === "custom" && ( + <> + { + if (validCustom) selectModel(customModel.trim()); + }} + /> + + Enter a model supported by a configured server provider, in provider:model format. - {custom ? ( - - ) : ( - - )} - - {custom && ( - <> - setValue({ ...value, model })} - placeholder="provider:model" - returnKeyType="done" - /> - - {validModel - ? "Use a model supported by a configured server provider." - : "Enter a model in provider:model format."} - - - )} - + + )} ); } -function Option(props: { label: string; selected: boolean; onPress: () => void }) { +function PickerRow(props: { + label: string; + subtitle?: string; + detail?: string; + accessibilityLabel?: string; + selected?: boolean; + separator?: boolean; + disclosure?: boolean; + icon?: LucideIcon; + onPress: () => void; +}) { + const Icon = props.icon; return ( [styles.row, pressed && { backgroundColor: colors.elevated }]} > - {props.selected && } - - {props.label} - + + {Icon && } + + {props.label} + {props.subtitle && {props.subtitle}} + + {props.detail && {props.detail}} + {props.selected && } + {props.disclosure && } + ); } const styles = StyleSheet.create({ + group: { borderRadius: radii.sheet, backgroundColor: colors.panel, overflow: "hidden" }, section: { gap: spacing.sm }, - chips: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm }, - option: { - minHeight: 44, - paddingHorizontal: spacing.md, - borderRadius: radii.control, - backgroundColor: colors.panel, - flexDirection: "row", - alignItems: "center", - gap: spacing.xs, - }, - modelRow: { + sectionLabel: { ...typography.footnote, color: colors.muted, paddingHorizontal: spacing.lg }, + row: { paddingHorizontal: spacing.lg }, + rowContent: { minHeight: 56, - padding: spacing.md, flexDirection: "row", alignItems: "center", gap: spacing.md, + paddingVertical: spacing.md, }, separator: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border }, - footnote: { ...typography.footnote, color: colors.muted }, - disclosure: { minHeight: 44, flexDirection: "row", gap: spacing.sm, alignItems: "center" }, + rowTitle: { ...typography.body, fontSize: 17, color: colors.bright }, + note: { ...typography.footnote, color: colors.muted }, + done: { minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center" }, }); diff --git a/packages/mobile/src/screens/formTestPlatform.ts b/packages/mobile/src/screens/formTestPlatform.ts index cdde9e41713..36f61646de5 100644 --- a/packages/mobile/src/screens/formTestPlatform.ts +++ b/packages/mobile/src/screens/formTestPlatform.ts @@ -20,6 +20,9 @@ mock.module("lucide-react-native", () => "ChevronDown", "ChevronRight", "Cpu", + "Bot", + "ClipboardList", + "Code2", "Folder", "MessageSquare", "KeyRound", diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index c2a86248a1b..e42900f2684 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -1,7 +1,7 @@ import "./formTestPlatform"; import { afterEach, expect, test } from "bun:test"; -import { createRef } from "react"; -import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { createRef, useState } from "react"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { createORPCClient } from "@orpc/client"; import { View } from "react-native"; import type { TextInput } from "react-native"; @@ -146,37 +146,144 @@ test("workspace creation cannot be dismissed or submitted twice while the server expect(selected).toBe(workspace); }); -test("model search accepts friendly names and returning preserves the selection", async () => { - const data: SettingsData = { - config: { agentAiDefaults: {}, defaultModel: "anthropic:claude-sonnet-4-5" }, - providers: { anthropic: { isConfigured: true, isEnabled: true, apiKeySet: true } }, - agents: [ - { id: "exec", name: "Exec", scope: "built-in", uiSelectable: true, subagentRunnable: true }, - ], - }; - let saved: ChatSettings | undefined; - const view = render( +const pickerValue: ChatSettings = { agentId: "exec", model: "local:one", thinkingLevel: "high" }; +const pickerData: SettingsData = { + config: { agentAiDefaults: {}, defaultModel: "local:one" }, + providers: { + local: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: ["one", "two", "three", "four", "five"], + }, + }, + agents: [ + { id: "exec", name: "Exec", scope: "built-in", uiSelectable: true, subagentRunnable: true }, + { + id: "plan", + name: "Plan", + description: "Plan before making changes", + scope: "built-in", + uiSelectable: true, + subagentRunnable: true, + aiDefaults: { model: "local:other" }, + }, + ], +}; +function PickerHarness(props: { + page?: "model" | "agent"; + onChange: (value: ChatSettings) => void; + onClose: () => void; +}) { + const [value, setValue] = useState(pickerValue); + return ( {}} - onSave={(value) => { - saved = value; + onChange={(next) => { + setValue(next); + props.onChange(next); + }} + onClose={props.onClose} + /> + ); +} + +test("model picks apply directly while keeping mode and effort", () => { + const changes: ChatSettings[] = []; + let closed = 0; + const view = render( + changes.push(value)} + onClose={() => { + closed++; + }} + /> + ); + expect(view.getByRole("radio", { name: "local:one" }).getAttribute("aria-checked")).toBe("true"); + expect(view.queryByRole("radio", { name: "local:five" })).toBeNull(); + fireEvent.click(view.getByRole("radio", { name: "local:two" })); + expect(changes).toEqual([{ ...pickerValue, model: "local:two" }]); + expect(closed).toBe(1); +}); + +test("changing effort returns to the model picker and closing retains the immediate choice", () => { + const changes: ChatSettings[] = []; + let closed = 0; + const view = render( + changes.push(value)} + onClose={() => { + closed++; }} /> ); - fireEvent.click(view.getByRole("button", { name: "Choose model" })); - fireEvent.change(view.getByLabelText("Search models"), { target: { value: "Sonnet 4.5" } }); - await waitFor(() => - expect(view.getByRole("button", { name: "anthropic:claude-sonnet-4-5" })).toBeDefined() + fireEvent.click(view.getByRole("button", { name: "Effort High" })); + fireEvent.click(view.getByRole("radio", { name: "Low" })); + expect(changes).toEqual([{ ...pickerValue, thinkingLevel: "low" }]); + expect(closed).toBe(0); + expect(view.getByRole("button", { name: "Effort Low" })).toBeDefined(); + fireEvent.click(view.getByRole("button", { name: "Close" })); + expect(closed).toBe(1); +}); + +test("mode selection preserves an explicit model and effort rather than resetting to agent defaults", () => { + const changes: ChatSettings[] = []; + let closed = 0; + const view = render( + changes.push(value)} + onClose={() => { + closed++; + }} + /> + ); + fireEvent.click(view.getByRole("radio", { name: /Plan/ })); + expect(changes).toEqual([{ ...pickerValue, agentId: "plan" }]); + expect(closed).toBe(1); +}); + +test("closing catalog search does not change the selection and all models remain searchable", () => { + const changes: ChatSettings[] = []; + const view = render( + changes.push(value)} onClose={() => {}} /> + ); + fireEvent.click(view.getByRole("button", { name: "More models" })); + fireEvent.change(view.getByLabelText("Search models"), { target: { value: "FIVE" } }); + expect(view.getByRole("radio", { name: "local:five" })).toBeDefined(); + expect(view.queryByRole("radio", { name: "local:one" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Back" })); + expect(changes).toHaveLength(0); + expect(view.getByRole("radio", { name: "local:one" }).getAttribute("aria-checked")).toBe("true"); +}); + +test("custom model drafts require valid input and explicit confirmation", () => { + const changes: ChatSettings[] = []; + let closed = 0; + const view = render( + changes.push(value)} + onClose={() => { + closed++; + }} + /> ); - fireEvent.change(view.getByLabelText("Search models"), { target: { value: "not-a-model" } }); - expect(view.queryByRole("button", { name: "anthropic:claude-sonnet-4-5" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "More models" })); + fireEvent.click(view.getByRole("button", { name: "Custom model" })); + fireEvent.change(view.getByLabelText("Model ID"), { target: { value: "missing-provider" } }); + fireEvent.click(view.getByRole("button", { name: "Use custom model" })); + expect(changes).toHaveLength(0); fireEvent.click(view.getByRole("button", { name: "Back" })); - fireEvent.click(view.getByRole("button", { name: "Use settings" })); - expect(saved?.model).toBe("anthropic:claude-sonnet-4-5"); - expect(saved?.thinkingLevel).toBe("high"); + expect(changes).toHaveLength(0); + fireEvent.click(view.getByRole("button", { name: "Custom model" })); + fireEvent.change(view.getByLabelText("Model ID"), { target: { value: " local:custom " } }); + expect(changes).toHaveLength(0); + fireEvent.click(view.getByRole("button", { name: "Use custom model" })); + expect(changes).toEqual([{ ...pickerValue, model: "local:custom" }]); + expect(closed).toBe(1); }); test("disconnect requires confirmation and can be cancelled without clearing credentials", () => { diff --git a/packages/mobile/src/theme.ts b/packages/mobile/src/theme.ts index 65f91eb9dee..802dbde6b89 100644 --- a/packages/mobile/src/theme.ts +++ b/packages/mobile/src/theme.ts @@ -10,6 +10,8 @@ export const colors = { bright: "hsl(0, 0%, 97%)", muted: "hsl(240, 3%, 64%)", dim: "hsl(240, 3%, 45%)", + sheet: "hsl(240, 3%, 8%)", + selection: "hsl(211, 80%, 65%)", accent: "hsl(268.56, 90%, 68%)", accentSurface: "hsla(268.56, 90%, 68%, 0.12)", plan: "hsl(210, 70%, 68%)", diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 17772054a2b..c352e167da7 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6986,7 +6986,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website.", "", - "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation and model settings use sheets with pinned actions. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Expand Thinking in conversation settings to adjust reasoning effort. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", + "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Use Effort or More models in the model picker for additional choices. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", "", "## Connect to a server", "", From 41febc6433a2d0ed35ad267e734f2258a71dad01 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 5 Sep 2026 22:24:36 +0000 Subject: [PATCH 13/84] =?UTF-8?q?=F0=9F=A4=96=20fix:=20search=20all=20visi?= =?UTF-8?q?ble=20models=20in=20the=20mobile=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the Settings-visible catalog directly in the native model sheet instead of a four-model provider shortlist. Reuse shared routing, catalog-accessibility and OpenAI auth rules; search provider names, friendly names and aliases without resurrecting removed discovery entries. Validate hidden and gateway models, cross-provider selection, draft/effort retention, and phone/wide layouts. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$353.19`_ --- docs/integrations/mobile-app.md | 2 +- packages/mobile/e2e/native-ux.spec.ts | 12 +-- .../mobile/src/screens/ConversationScreen.tsx | 4 +- packages/mobile/src/screens/ModelSettings.tsx | 62 ++++--------- .../mobile/src/screens/forms.behavior.tsx | 24 +++-- packages/mobile/src/settings.test.ts | 70 ++++++++++++++- packages/mobile/src/settings.ts | 87 ++++++++++++++++--- .../builtInSkillContent.generated.ts | 2 +- 8 files changed, 189 insertions(+), 74 deletions(-) diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index 17afa16177a..7b7102b5510 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -5,7 +5,7 @@ description: Develop the React Native Xum companion and connect it to your serve The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website. -It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Use Effort or More models in the model picker for additional choices. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. +It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Search models directly in the model picker by name, provider, or alias. The grouped list follows model visibility in Settings and includes models available through configured gateway routes; the current selection remains visible even if subsequently hidden. Use Effort to adjust thinking. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. ## Connect to a server diff --git a/packages/mobile/e2e/native-ux.spec.ts b/packages/mobile/e2e/native-ux.spec.ts index f20b9103a36..f689e3293ec 100644 --- a/packages/mobile/e2e/native-ux.spec.ts +++ b/packages/mobile/e2e/native-ux.spec.ts @@ -52,11 +52,13 @@ test("native stack preserves drafts and sheets keep their actions reachable", as const chooseModel = page.getByRole("button", { name: "Choose model", exact: true }); await chooseModel.click(); - const more = page.getByRole("button", { name: "More models", exact: true }); - await withinViewport(page, more); - await more.click(); - await page.getByRole("textbox", { name: "Search models" }).fill("no-match-model-query"); - await page.getByRole("button", { name: "Back", exact: true }).click(); + const search = page.getByRole("textbox", { name: "Search models" }); + await withinViewport(page, search); + await expect(page.getByRole("radio").first()).toBeVisible(); + await search.fill("no-match-model-query"); + await expect(page.getByRole("radio")).toHaveCount(0); + await search.fill(""); + await expect(page.getByRole("radio").first()).toBeVisible(); await page.getByRole("button", { name: /^Effort/ }).click(); await page.getByRole("radio", { name: "High", exact: true }).click(); await expect(page.getByRole("button", { name: /^Effort/ })).toContainText("High"); diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index df28a709b24..87f6186f7e3 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -25,9 +25,9 @@ import { Button, IconButton, Loading, Notice } from "../components/Controls"; import { Message } from "../components/Message"; import { useConversation } from "../useConversation"; import { linkedAbortController } from "../useConnection"; -import { resolveSettings } from "../settings"; +import { modelName, resolveSettings } from "../settings"; import type { ChatSettings } from "../settings"; -import { ModelSettings, modelName } from "./ModelSettings"; +import { ModelSettings } from "./ModelSettings"; import { colors, fontFamily, layout, radii, spacing, typography } from "../theme"; import { DEFAULT_THINKING_LEVEL } from "../../../../src/common/types/thinking"; diff --git a/packages/mobile/src/screens/ModelSettings.tsx b/packages/mobile/src/screens/ModelSettings.tsx index ed599d7fdc8..c0224a85228 100644 --- a/packages/mobile/src/screens/ModelSettings.tsx +++ b/packages/mobile/src/screens/ModelSettings.tsx @@ -3,17 +3,21 @@ import { Pressable, StyleSheet, Text, View } from "react-native"; import { Bot, Check, ChevronRight, ClipboardList, Code2 } from "lucide-react-native"; import type { LucideIcon } from "lucide-react-native"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; -import { formatModelDisplayName } from "../../../../src/common/utils/ai/modelDisplay"; import { Field, Sheet } from "../components/Controls"; -import { modelChoices, resolveSettings, thinkingLevels } from "../settings"; +import { + modelChoices, + modelName, + modelMatchesSearch, + resolveSettings, + thinkingLevels, +} from "../settings"; import type { ChatSettings, SettingsData } from "../settings"; import type { ThinkingLevel } from "../../../../src/common/types/thinking"; import { colors, layout, radii, spacing, typography } from "../theme"; -type Page = "model" | "catalog" | "effort" | "agent" | "custom"; +type Page = "model" | "effort" | "agent" | "custom"; const titles: Record = { model: "Select model", - catalog: "More models", effort: "Effort", agent: "Select mode", custom: "Custom model", @@ -27,15 +31,6 @@ const effortLabels: Record = { max: "Maximum", }; -export function modelName(id: string) { - return formatModelDisplayName( - id - .slice(id.indexOf(":") + 1) - .split("/") - .at(-1) ?? id - ); -} - export function ModelSettings(props: { initialPage: "model" | "agent"; value: ChatSettings; @@ -48,14 +43,9 @@ export function ModelSettings(props: { const [query, setQuery] = useState(""); const [customModel, setCustomModel] = useState(props.value.model); const models = modelChoices(props.data, props.value.model); - const currentProvider = props.value.model.split(":")[0]; - // Start with the current choice and its provider, not invented recommendations or capability claims. - const featured = [ - ...models.filter((model) => model.split(":")[0] === currentProvider), - ...models.filter((model) => model.split(":")[0] !== currentProvider), - ].slice(0, 4); + // Search the entire Settings-visible catalog in the main sheet, not a provider shortlist. const filtered = models.filter((model) => - `${model} ${modelName(model)}`.toLowerCase().includes(query.trim().toLowerCase()) + modelMatchesSearch(model, query, providerName(model.split(":")[0])) ); const groups = new Map(); for (const model of filtered) { @@ -88,13 +78,7 @@ export function ModelSettings(props: { variant="picker" title={titles[page]} onClose={props.onClose} - onBack={ - page === "catalog" || page === "effort" - ? () => setPage("model") - : page === "custom" - ? () => setPage("catalog") - : undefined - } + onBack={page === "effort" || page === "custom" ? () => setPage("model") : undefined} action={ page === "custom" && ( {page === "model" && ( <> - {featured.map(modelRow)} + - - setPage("catalog")} disclosure /> - - - )} - {page === "catalog" && ( - <> - {[...groups].map(([provider, choices]) => ( {providerName(provider)} diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index e42900f2684..c0e221de32a 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -148,7 +148,7 @@ test("workspace creation cannot be dismissed or submitted twice while the server const pickerValue: ChatSettings = { agentId: "exec", model: "local:one", thinkingLevel: "high" }; const pickerData: SettingsData = { - config: { agentAiDefaults: {}, defaultModel: "local:one" }, + config: { agentAiDefaults: {}, defaultModel: "local:one", hiddenModels: ["other:hidden"] }, providers: { local: { isConfigured: true, @@ -156,6 +156,13 @@ const pickerData: SettingsData = { apiKeySet: true, models: ["one", "two", "three", "four", "five"], }, + other: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + displayName: "Team models", + models: ["visible", "hidden"], + }, }, agents: [ { id: "exec", name: "Exec", scope: "built-in", uiSelectable: true, subagentRunnable: true }, @@ -203,7 +210,9 @@ test("model picks apply directly while keeping mode and effort", () => { /> ); expect(view.getByRole("radio", { name: "local:one" }).getAttribute("aria-checked")).toBe("true"); - expect(view.queryByRole("radio", { name: "local:five" })).toBeNull(); + expect(view.getByRole("radio", { name: "local:five" })).toBeDefined(); + expect(view.getByRole("radio", { name: "other:visible" })).toBeDefined(); + expect(view.queryByRole("radio", { name: "other:hidden" })).toBeNull(); fireEvent.click(view.getByRole("radio", { name: "local:two" })); expect(changes).toEqual([{ ...pickerValue, model: "local:two" }]); expect(closed).toBe(1); @@ -246,18 +255,20 @@ test("mode selection preserves an explicit model and effort rather than resettin expect(closed).toBe(1); }); -test("closing catalog search does not change the selection and all models remain searchable", () => { +test("searching the main picker spans providers without changing selection", () => { const changes: ChatSettings[] = []; const view = render( changes.push(value)} onClose={() => {}} /> ); - fireEvent.click(view.getByRole("button", { name: "More models" })); fireEvent.change(view.getByLabelText("Search models"), { target: { value: "FIVE" } }); expect(view.getByRole("radio", { name: "local:five" })).toBeDefined(); expect(view.queryByRole("radio", { name: "local:one" })).toBeNull(); - fireEvent.click(view.getByRole("button", { name: "Back" })); + fireEvent.change(view.getByLabelText("Search models"), { target: { value: "TEAM" } }); + expect(view.getByRole("radio", { name: "other:visible" })).toBeDefined(); + expect(view.queryByRole("radio", { name: "other:hidden" })).toBeNull(); expect(changes).toHaveLength(0); - expect(view.getByRole("radio", { name: "local:one" }).getAttribute("aria-checked")).toBe("true"); + fireEvent.click(view.getByRole("radio", { name: "other:visible" })); + expect(changes).toEqual([{ ...pickerValue, model: "other:visible" }]); }); test("custom model drafts require valid input and explicit confirmation", () => { @@ -271,7 +282,6 @@ test("custom model drafts require valid input and explicit confirmation", () => }} /> ); - fireEvent.click(view.getByRole("button", { name: "More models" })); fireEvent.click(view.getByRole("button", { name: "Custom model" })); fireEvent.change(view.getByLabelText("Model ID"), { target: { value: "missing-provider" } }); fireEvent.click(view.getByRole("button", { name: "Use custom model" })); diff --git a/packages/mobile/src/settings.test.ts b/packages/mobile/src/settings.test.ts index e15be2f9626..26bf455524c 100644 --- a/packages/mobile/src/settings.test.ts +++ b/packages/mobile/src/settings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { KNOWN_MODELS } from "../../../src/common/constants/knownModels"; -import { modelChoices, resolveSettings, type SettingsData } from "./settings"; +import { modelChoices, modelMatchesSearch, resolveSettings, type SettingsData } from "./settings"; function data(): SettingsData { return { @@ -31,6 +31,74 @@ describe("mobile model settings", () => { expect(options.filter((id) => id === hidden)).toHaveLength(1); expect(options).toContain("anthropic:custom-model"); }); + test("shows configured providers and gateway-only models without exposing disabled providers", () => { + const config = data(); + config.providers.openai.isEnabled = true; + config.providers.google.isConfigured = true; + expect(modelChoices(config, "")).toContain(KNOWN_MODELS.GPT.id); + expect(modelChoices(config, "")).toContain(KNOWN_MODELS.GEMINI_FLASH.id); + config.providers.openai.isEnabled = false; + config.providers.google.isConfigured = false; + config.providers.coder = { + isEnabled: true, + isConfigured: true, + apiKeySet: false, + models: ["openai/gpt-5.6-sol"], + discoveredModels: ["openai/gpt-5.6-sol"], + }; + config.config = { ...config.config, routePriority: ["coder", "direct"] }; + expect(modelChoices(config, "")).toContain("openai:gpt-5.6-sol"); + expect(modelChoices(config, "")).not.toContain(KNOWN_MODELS.GEMINI_FLASH.id); + }); + test("does not resurrect removed discovery entries and honors hidden gateway models", () => { + const config = data(); + config.providers.coder = { + isEnabled: true, + isConfigured: true, + apiKeySet: false, + models: ["openai/gpt-5.6-sol"], + discoveredModels: ["openai/gpt-5.6-sol", "vendor/removed"], + removedModels: ["vendor/removed"], + }; + config.config = { + ...config.config, + routePriority: ["coder"], + hiddenModels: ["openai:gpt-5.6-sol"], + }; + const choices = modelChoices(config, ""); + expect(choices).toContain("coder:openai/gpt-5.6-sol"); + expect(choices).not.toContain("coder:vendor/removed"); + expect(choices).not.toContain("openai:gpt-5.6-sol"); + }); + test("uses the OpenAI authentication gates from the web picker", () => { + const config = data(); + config.providers.openai = { + isEnabled: true, + isConfigured: true, + apiKeySet: false, + codexOauthSet: true, + models: ["gpt-5.6-sol", "gpt-4o", "gpt-5.3-codex-spark"], + }; + expect(modelChoices(config, "")).toContain("openai:gpt-5.6-sol"); + expect(modelChoices(config, "")).not.toContain("openai:gpt-4o"); + config.providers.openai.apiKeySet = true; + config.providers.openai.codexOauthSet = false; + expect(modelChoices(config, "")).toContain("openai:gpt-4o"); + expect(modelChoices(config, "")).not.toContain("openai:gpt-5.3-codex-spark"); + }); + test("search matches friendly names, provider names, and canonical aliases across routes", () => { + expect(modelMatchesSearch("anthropic:claude-sonnet-5", "Sonnet 5", "Anthropic")).toBe(true); + expect(modelMatchesSearch("custom:opaque-id", " TEAM ", "Team models")).toBe(true); + expect(modelMatchesSearch(KNOWN_MODELS.GEMINI_FLASH.id, "gemini-flash", "Google")).toBe(true); + expect( + modelMatchesSearch( + `mux-gateway:${KNOWN_MODELS.GEMINI_FLASH.id.replace(":", "/")}`, + "gemini-flash", + "Mux Gateway" + ) + ).toBe(true); + expect(modelMatchesSearch("anthropic:claude-sonnet-5", "not-a-model", "Anthropic")).toBe(false); + }); test("resolves agent-scoped workspace settings ahead of global preferences", () => { const config = data(); config.config.defaultModel = "fallback:model"; diff --git a/packages/mobile/src/settings.ts b/packages/mobile/src/settings.ts index 3eb24436b80..27492a2b5f7 100644 --- a/packages/mobile/src/settings.ts +++ b/packages/mobile/src/settings.ts @@ -1,4 +1,16 @@ -import { DEFAULT_MODEL, KNOWN_MODELS } from "../../../src/common/constants/knownModels"; +import { + DEFAULT_MODEL, + KNOWN_MODELS, + MODEL_ABBREVIATIONS, +} from "../../../src/common/constants/knownModels"; +import { + isCodexOauthAllowedModel, + isCodexOauthRequiredModel, +} from "../../../src/common/constants/codexOAuth"; +import { isModelAvailable } from "../../../src/common/routing"; +import { normalizeToCanonical } from "../../../src/common/utils/ai/models"; +import { formatModelDisplayName } from "../../../src/common/utils/ai/modelDisplay"; +import { isProviderModelAccessibleFromAuthoritativeCatalog } from "../../../src/common/utils/providers/gatewayModelCatalog"; import type { MobileClient } from "./api"; import type { FrontendWorkspaceMetadata } from "../../../src/common/types/workspace"; import type { SendMessageOptions } from "../../../src/common/orpc/types"; @@ -7,7 +19,7 @@ import type { ThinkingLevel } from "../../../src/common/types/thinking"; export type SettingsData = { config: Pick< Awaited>, - "agentAiDefaults" | "defaultModel" | "hiddenModels" + "agentAiDefaults" | "defaultModel" | "hiddenModels" | "routePriority" | "routeOverrides" >; providers: Awaited>; agents: Awaited>; @@ -47,20 +59,67 @@ export function resolveSettings( export function modelChoices(data: SettingsData, currentModel: string): string[] { const models = new Set(); if (currentModel) models.add(currentModel); - if (data.config.defaultModel) models.add(data.config.defaultModel); - // getConfig lists custom/discovered models, not the built-in desktop catalog. - for (const model of Object.values(KNOWN_MODELS)) { - const provider = data.providers[model.provider]; - if (provider?.isConfigured && provider.isEnabled) models.add(model.id); - } + // Match the web Settings catalog: `models` is the user-visible union; raw discovery + // can still contain removed entries. Gateway duplicates use canonical model rows. for (const [provider, config] of Object.entries(data.providers)) { - if (!config.isEnabled || !config.isConfigured) continue; - for (const entry of [...(config.models ?? []), ...(config.discoveredModels ?? [])]) { - const id = typeof entry === "string" ? entry : entry.id; - models.add(`${provider}:${id}`); + if (!config.isEnabled || provider === "mux-gateway" || provider === "github-copilot") continue; + for (const entry of config.models ?? []) { + models.add(`${provider}:${typeof entry === "string" ? entry : entry.id}`); } } - return [...models].filter( - (model) => model === currentModel || !data.config.hiddenModels?.includes(model) + for (const model of Object.values(KNOWN_MODELS)) models.add(model.id); + const isConfigured = (provider: string) => + data.providers[provider]?.isConfigured === true && + data.providers[provider]?.isEnabled !== false; + const isAccessible = (provider: string, modelId: string) => { + const config = data.providers[provider]; + return isProviderModelAccessibleFromAuthoritativeCatalog( + provider, + modelId, + config?.models, + config?.discoveredModels, + config?.removedModels + ); + }; + return [...models].filter((model) => { + // Retain the active choice even if Settings subsequently hides or disables it. + if (model === currentModel) return true; + if (data.config.hiddenModels?.includes(model)) return false; + const colon = model.indexOf(":"); + if (!isAccessible(model.slice(0, colon), model.slice(colon + 1))) return false; + if ( + !isModelAvailable( + model, + data.config.routePriority ?? ["direct"], + data.config.routeOverrides ?? {}, + isConfigured, + isAccessible + ) + ) + return false; + if (!model.startsWith("openai:")) return true; + const openai = data.providers.openai; + if (openai?.apiKeySet && openai.codexOauthSet) return true; + if (!openai?.apiKeySet && openai?.codexOauthSet) + return isCodexOauthAllowedModel(model, data.providers); + return !isCodexOauthRequiredModel(model, data.providers); + }); +} + +export function modelName(id: string): string { + return formatModelDisplayName( + id + .slice(id.indexOf(":") + 1) + .split("/") + .at(-1) ?? id + ); +} + +export function modelMatchesSearch(model: string, query: string, providerName: string): boolean { + const search = query.trim().toLowerCase(); + if (`${model} ${modelName(model)} ${providerName}`.toLowerCase().includes(search)) return true; + const canonical = normalizeToCanonical(model); + return Object.entries(MODEL_ABBREVIATIONS).some( + ([alias, id]) => id === canonical && alias.includes(search) ); } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index c352e167da7..ecbe58b5914 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6986,7 +6986,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website.", "", - "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Use Effort or More models in the model picker for additional choices. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", + "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Search models directly in the model picker by name, provider, or alias. The grouped list follows model visibility in Settings and includes models available through configured gateway routes; the current selection remains visible even if subsequently hidden. Use Effort to adjust thinking. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", "", "## Connect to a server", "", From eed16264d935c72a707573faa054cddb747840d9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 6 Sep 2026 16:27:16 +0000 Subject: [PATCH 14/84] =?UTF-8?q?=F0=9F=A4=96=20feat:=20refine=20mobile=20?= =?UTF-8?q?navigation=20and=20bottom=20composer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring phone workspace search and creation into a fixed bottom dock, show project/server context in left-aligned conversation headers, and group navigation actions. Keep the input bottommost with Plan/model controls above it. Expand text entry for focus/drafts while preserving web picker clicks through browser focus retention rather than timers or moving controls below the input. Draw focus on the rounded composer boundary. Validate phone/wide/short layouts, keyboard and pointer picker activation, draft/settings retention, send/interrupt controls, and native bundling. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$428.45`_ --- docs/integrations/mobile-app.md | 2 +- packages/mobile/App.tsx | 6 +- packages/mobile/e2e/native-ux.spec.ts | 80 ++++- .../mobile/src/screens/ConversationScreen.tsx | 207 +++++++----- packages/mobile/src/screens/Navigator.tsx | 300 +++++++++++------- packages/mobile/src/theme.ts | 2 + .../builtInSkillContent.generated.ts | 2 +- 7 files changed, 391 insertions(+), 208 deletions(-) diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index 7b7102b5510..aa61a6fd639 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -5,7 +5,7 @@ description: Develop the React Native Xum companion and connect it to your serve The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website. -It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Search models directly in the model picker by name, provider, or alias. The grouped list follows model visibility in Settings and includes models available through configured gateway routes; the current selection remains visible even if subsequently hidden. Use Effort to adjust thinking. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. +It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Phone workspace search and creation stay in a bottom dock, while wide layouts keep their controls above the list. Conversation headers show project and server context with grouped navigation actions. Drafts and unsent model choices survive returning to the list. The composer stays compact when empty and unfocused, expands for writing, and stays at the bottom with mode/model controls directly above it. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Search models directly in the model picker by name, provider, or alias. The grouped list follows model visibility in Settings and includes models available through configured gateway routes; the current selection remains visible even if subsequently hidden. Use Effort to adjust thinking. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app. ## Connect to a server diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx index 319bc9e4bcb..61d6a5cefd2 100644 --- a/packages/mobile/App.tsx +++ b/packages/mobile/App.tsx @@ -17,7 +17,7 @@ import { SettingsScreen } from "./src/screens/SettingsScreen"; import { Button, Header, Loading, Notice } from "./src/components/Controls"; import { useProjects } from "./src/useProjects"; import { useConnection } from "./src/useConnection"; -import { colors, layout } from "./src/theme"; +import { colors, layout, WIDE_LAYOUT_MIN_WIDTH } from "./src/theme"; import type { ChatSettings } from "./src/settings"; export type MobileRoutes = { @@ -201,7 +201,7 @@ function ScreenLayout(props: { const { width } = useWindowDimensions(); return ( - {width >= 900 && ( + {width >= WIDE_LAYOUT_MIN_WIDTH && ( props.navigation.popTo("Workspaces")} onChanges={() => props.navigation.navigate("Changes", { workspaceId })} + onSettings={() => props.navigation.navigate("Settings")} selection={selections[workspaceId] ?? null} onSelectionChange={(value) => setSelection(workspaceId, value)} draft={drafts[workspaceId] ?? ""} diff --git a/packages/mobile/e2e/native-ux.spec.ts b/packages/mobile/e2e/native-ux.spec.ts index f689e3293ec..82ed873de82 100644 --- a/packages/mobile/e2e/native-ux.spec.ts +++ b/packages/mobile/e2e/native-ux.spec.ts @@ -26,7 +26,31 @@ test("native stack preserves drafts and sheets keep their actions reachable", as await expect(page.getByRole("textbox", { name: "Bearer token", exact: true })).toBeFocused(); await page.getByRole("textbox", { name: "Bearer token", exact: true }).fill(token); await page.getByRole("button", { name: /^Connect(?: without encryption)?$/ }).click(); - await expect(page.getByRole("textbox", { name: "Search workspaces" })).toBeVisible(); + const workspaceSearch = page.getByRole("textbox", { name: "Search workspaces" }); + const newWorkspace = page.getByRole("button", { name: "New workspace", exact: true }).first(); + await expect(workspaceSearch).toBeVisible(); + const viewport = page.viewportSize()!; + await expect + .poll(async () => { + const y = (await workspaceSearch.boundingBox())!.y; + return viewport.width < 900 ? y > viewport.height - 130 : y < 180; + }) + .toBe(true); + await withinViewport(page, newWorkspace); + const searchY = (await workspaceSearch.boundingBox())!.y; + await page.getByTestId("workspace-list").evaluate((list) => { + list.scrollTop = list.scrollHeight; + }); + await expect.poll(async () => (await workspaceSearch.boundingBox())!.y).toBe(searchY); + await workspaceSearch.fill("keep this query while resizing"); + await page.setViewportSize({ + width: viewport.width < 900 ? 1200 : 375, + height: viewport.height, + }); + await expect(workspaceSearch).toHaveValue("keep this query while resizing"); + await expect(workspaceSearch).toBeFocused(); + await page.setViewportSize(viewport); + await workspaceSearch.fill(""); await page.getByRole("button", { name: "New workspace", exact: true }).first().click(); const create = page.getByRole("button", { name: "Create scratch chat", exact: true }); @@ -36,9 +60,52 @@ test("native stack preserves drafts and sheets keep their actions reachable", as await expect(page.getByRole("textbox", { name: "Message", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); const message = page.getByRole("textbox", { name: "Message", exact: true }); + await expect( + page.getByText(`Scratch chat · ${new URL(endpoint).host}`, { exact: true }) + ).toBeVisible(); + await expect.poll(async () => (await message.boundingBox())!.height).toBeLessThanOrEqual(50); + const modeControl = page.getByRole("button", { name: "Choose mode", exact: true }); + const modelControl = page.getByRole("button", { name: "Choose model", exact: true }); + for (const control of [modeControl, modelControl]) { + await expect + .poll(async () => { + const button = (await control.boundingBox())!; + const input = (await message.boundingBox())!; + return button.y + button.height <= input.y; + }) + .toBe(true); + } + await message.focus(); + await expect(message).toBeFocused(); + await expect.poll(async () => (await message.boundingBox())!.height).toBeGreaterThan(60); + // Pointer-down blurs the input before click: the toolbar must not shift below that press. + const modelBounds = (await modelControl.boundingBox())!; + await page.mouse.move( + modelBounds.x + modelBounds.width / 2, + modelBounds.y + modelBounds.height / 2 + ); + await page.mouse.down(); + await expect.poll(async () => (await message.boundingBox())!.height).toBeGreaterThan(60); + await expect.poll(async () => (await modelControl.boundingBox())!.y).toBe(modelBounds.y); + await expect(page.getByRole("textbox", { name: "Search models" })).toHaveCount(0); + await page.mouse.up(); + await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await message.focus(); + await page.getByRole("button", { name: "Choose mode", exact: true }).click(); + await expect(page.getByRole("radio", { name: /^Plan/ })).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await modelControl.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await message.focus(); await message.fill("A line of a longer draft\n".repeat(10)); await expect.poll(async () => (await message.boundingBox())!.height).toBeGreaterThan(80); await message.fill(""); + await expect.poll(async () => (await message.boundingBox())!.height).toBeLessThanOrEqual(80); + await page.getByRole("button", { name: "Connection settings", exact: true }).click(); + await page.getByRole("button", { name: "Back", exact: true }).click(); await expect.poll(async () => (await message.boundingBox())!.height).toBeLessThanOrEqual(50); const draft = "Keep this unsent draft while navigating."; await message.fill(draft); @@ -81,7 +148,7 @@ test("native stack preserves drafts and sheets keep their actions reachable", as await page.getByRole("button", { name: "Close", exact: true }).click(); await message.fill(""); - const viewport = page.viewportSize()!; + await message.blur(); await page.setViewportSize({ width: viewport.width === 1200 ? 375 : 1200, height: viewport.height, @@ -92,6 +159,15 @@ test("native stack preserves drafts and sheets keep their actions reachable", as expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( true ); + // Exercise the relocated send/stop targets through the real disposable server. + await message.fill("[mock:tool:parallel-step]"); + await send.click(); + const interrupt = page.getByRole("button", { name: "Interrupt agent", exact: true }); + await expect(interrupt).toBeVisible(); + await withinViewport(page, interrupt); + await interrupt.click(); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); + await expect(message).toHaveValue(""); expect( await page.evaluate( (secret) => diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 87f6186f7e3..a7222118dcc 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -14,6 +14,8 @@ import { ArrowDown, ArrowUp, ChevronDown, + ClipboardList, + Settings, GitCompareArrows, ChevronLeft, Square, @@ -33,10 +35,17 @@ import { DEFAULT_THINKING_LEVEL } from "../../../../src/common/types/thinking"; // RN Web reports scrollHeight, which cannot shrink a fixed-height textarea and // can expand hidden stack screens. Let the browser size content; native uses its intrinsic measurement. -const webInputSizing = { fieldSizing: "content", height: "auto" } as const; +// Focus belongs on the rounded composer. Browser "auto" outlines can still paint at zero width. +const webInputSizing = { + fieldSizing: "content", + height: "auto", + outlineStyle: "solid", + outlineWidth: 0, +} as const; export function ConversationScreen(props: { client: MobileClient; + serverLabel: string; workspace: FrontendWorkspaceMetadata; signal: AbortSignal; connected: boolean; @@ -47,6 +56,7 @@ export function ConversationScreen(props: { draft: string; onDraftChange: (value: SetStateAction) => void; onChanges: () => void; + onSettings: () => void; }) { const { transcript, settings, error, loadOlder, loadingOlder, historyError } = useConversation( props.client, @@ -55,6 +65,7 @@ export function ConversationScreen(props: { ); const draft = props.draft; const setDraft = props.onDraftChange; + const [inputFocused, setInputFocused] = useState(false); const [inputHeight, setInputHeight] = useState(44); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); @@ -79,6 +90,7 @@ export function ConversationScreen(props: { const ready = props.connected && !props.signal.aborted && transcript.caughtUp && !error && settings !== null; const running = ready && transcript.streaming; + const expanded = inputFocused || draft.length > 0 || running || showSettings !== null; async function send() { if (!ready || !options?.model || !draft.trim() || pending.current || running) return; @@ -156,28 +168,32 @@ export function ConversationScreen(props: { behavior={Platform.OS === "ios" ? "padding" : "height"} > - + + + - + {props.workspace.title ?? props.workspace.name} - {props.workspace.kind === "scratch" - ? "Scratch chat" - : `${props.workspace.projectName} / ${props.workspace.name}`} + {props.workspace.kind === "scratch" ? "Scratch chat" : props.workspace.projectName} ·{" "} + {props.serverLabel} - + + + + )} - + {/* Keep the input bottommost. Pointer presses retain browser focus until click opens the picker, avoiding blur-driven movement. */} + + + event.preventDefault() : undefined} + onPress={() => setShowSettings("agent")} + style={({ pressed }) => [ + styles.modelButton, + { maxWidth: "45%" }, + pressed && { opacity: 0.6 }, + ]} + > + {options?.agentId === "plan" ? ( + + ) : ( + + )} + + {settings?.agents.find((agent) => agent.id === options?.agentId)?.name ?? "Mode"} + + + + event.preventDefault() : undefined} + onPress={() => setShowSettings("model")} + style={({ pressed }) => [styles.modelButton, pressed && { opacity: 0.6 }]} + > + + {options?.model ? modelName(options.model) : "Model"} + + + + + + setInputFocused(true)} + onBlur={() => setInputFocused(false)} editable={!busy} onContentSizeChange={ Platform.OS === "web" @@ -283,65 +349,31 @@ export function ConversationScreen(props: { Math.max(44, Math.min(132, event.nativeEvent.contentSize.height)) ) } - style={[styles.input, Platform.OS === "web" ? webInputSizing : { height: inputHeight }]} + style={[ + styles.input, + expanded && styles.expandedInput, + Platform.OS === "web" + ? webInputSizing + : { height: expanded ? Math.max(72, inputHeight) : 44 }, + ]} selectionColor={colors.accent} /> - - - setShowSettings("agent")} - style={({ pressed }) => [ - styles.modelButton, - { maxWidth: "45%" }, - pressed && { opacity: 0.6 }, - ]} - > - - - {settings?.agents.find((agent) => agent.id === options?.agentId)?.name ?? "Mode"} - - - - setShowSettings("model")} - style={({ pressed }) => [styles.modelButton, pressed && { opacity: 0.6 }]} - > - - {options?.model ? modelName(options.model) : "Model"} - - - - - - - + + @@ -361,14 +393,15 @@ export function ConversationScreen(props: { const styles = StyleSheet.create({ header: { - minHeight: 56, - paddingHorizontal: 8, + minHeight: 72, + paddingHorizontal: spacing.md, flexDirection: "row", alignItems: "center", - gap: 4, + gap: spacing.sm, }, - title: { ...typography.header, color: colors.bright, textAlign: "center", fontSize: 16 }, - subtitle: { ...typography.footnote, color: colors.muted, textAlign: "center", fontSize: 12 }, + headerActions: { flexDirection: "row", borderRadius: radii.pill, backgroundColor: colors.panel }, + title: { ...typography.header, color: colors.bright }, + subtitle: { ...typography.footnote, color: colors.muted, marginTop: 2 }, messages: { paddingHorizontal: spacing.xl, paddingTop: 20, @@ -400,17 +433,23 @@ const styles = StyleSheet.create({ width: "100%", maxWidth: 760, alignSelf: "center", - gap: 8, + gap: 4, backgroundColor: colors.background, }, composer: { - borderRadius: radii.sheet, + flexDirection: "row", + alignItems: "flex-end", + borderRadius: radii.pill, padding: 6, backgroundColor: colors.panel, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth, }, + expandedComposer: { borderRadius: radii.sheet }, + focusedComposer: { borderColor: colors.selection }, + expandedInput: { minHeight: 72 }, input: { + flex: 1, fontFamily, minWidth: 0, color: colors.bright, @@ -437,10 +476,10 @@ const styles = StyleSheet.create({ gap: 6, flexShrink: 1, paddingHorizontal: 10, - backgroundColor: colors.elevated, + backgroundColor: colors.panel, borderRadius: radii.pill, }, - modeDot: { width: 6, height: 6, borderRadius: 3 }, + modeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.accent }, modelLabel: { fontFamily, color: colors.text, fontSize: 13, fontWeight: "500", flexShrink: 1 }, send: { borderRadius: 22, overflow: "hidden", backgroundColor: colors.elevated }, latest: { diff --git a/packages/mobile/src/screens/Navigator.tsx b/packages/mobile/src/screens/Navigator.tsx index 02d8cf6b759..ce8b26e9652 100644 --- a/packages/mobile/src/screens/Navigator.tsx +++ b/packages/mobile/src/screens/Navigator.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import { + KeyboardAvoidingView, + Platform, Pressable, RefreshControl, ScrollView, @@ -7,6 +9,7 @@ import { Text, TextInput, View, + useWindowDimensions, } from "react-native"; import { ChevronDown, @@ -16,12 +19,21 @@ import { Plus, Search, Settings, + SquarePen, X, } from "lucide-react-native"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; import type { Projects } from "../useProjects"; import { Button, IconButton, Loading, Notice } from "../components/Controls"; -import { colors, fontFamily, layout, radii, spacing, typography } from "../theme"; +import { + colors, + fontFamily, + layout, + radii, + spacing, + typography, + WIDE_LAYOUT_MIN_WIDTH, +} from "../theme"; export function Navigator(props: { projects: Projects; @@ -35,6 +47,8 @@ export function Navigator(props: { onSettings: () => void; compact?: boolean; }) { + const { width } = useWindowDimensions(); + const bottomDock = !props.compact && width < WIDE_LAYOUT_MIN_WIDTH; const [collapsed, setCollapsed] = useState>(() => new Set()); const [query, setQuery] = useState(""); const groups = new Map(); @@ -61,142 +75,190 @@ export function Navigator(props: { ([, group]) => !query.trim() || group.workspaces.length > 0 ); return ( - + {props.compact ? "Xum" : "Workspaces"} - - - - 0} - onRefresh={props.onRetry} - tintColor={colors.accent} - /> - } - > - - - - {query.length > 0 && ( - setQuery("")} /> + {!bottomDock && ( + )} - {props.error && {props.error}} - {props.loading && props.workspaces.length === 0 && } - {!props.loading && props.workspaces.length === 0 && !props.error && ( - - - Start a conversation - - Create a workspace in a project, or a scratch chat for a quick question. - - - - )} - {query.trim() && visibleGroups.length === 0 ? ( - - No matching workspaces - Try a project name, branch, or conversation title. + + {/* Keep one search input mounted across breakpoints; on phones it belongs in thumb reach. */} + + + + + + {query.length > 0 && ( + setQuery("")} /> + )} - ) : null} - {visibleGroups.map(([key, group]) => ( - + {bottomDock && ( - setCollapsed((current) => { - const next = new Set(current); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }) - } - style={styles.groupHeader} + accessibilityLabel="New workspace" + onPress={props.onCreate} + style={({ pressed }) => [styles.createButton, pressed && { opacity: 0.7 }]} > - {key === "scratch" ? ( - - ) : ( - - )} - - {group.name} - - {group.workspaces.length} - {collapsed.has(key) && !query.trim() ? ( - - ) : ( - - )} + + New - {(!collapsed.has(key) || Boolean(query.trim())) && ( - - {group.workspaces.length === 0 ? ( - No conversations yet + )} + + 0} + onRefresh={props.onRetry} + tintColor={colors.accent} + /> + } + > + {props.error && {props.error}} + {props.loading && props.workspaces.length === 0 && ( + + )} + {!props.loading && props.workspaces.length === 0 && !props.error && ( + + + Start a conversation + + Create a workspace in a project, or a scratch chat for a quick question. + + + + )} + {query.trim() && visibleGroups.length === 0 ? ( + + No matching workspaces + Try a project name, branch, or conversation title. + + ) : null} + {visibleGroups.map(([key, group]) => ( + + + setCollapsed((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }) + } + style={styles.groupHeader} + > + {key === "scratch" ? ( + ) : ( - group.workspaces - .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? "")) - .map((workspace) => ( - props.onSelect(workspace)} - style={({ pressed }) => [ - styles.workspace, - (workspace.id === props.selectedId || pressed) && { - backgroundColor: colors.elevated, - }, - ]} - > - - - {workspace.title ?? workspace.name} - - - {workspace.kind === "scratch" ? "Scratch chat" : workspace.name} - - - - )) + )} - - )} - - ))} - - + + {group.name} + + {group.workspaces.length} + {collapsed.has(key) && !query.trim() ? ( + + ) : ( + + )} + + {(!collapsed.has(key) || Boolean(query.trim())) && ( + + {group.workspaces.length === 0 ? ( + No conversations yet + ) : ( + group.workspaces + .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? "")) + .map((workspace) => ( + props.onSelect(workspace)} + style={({ pressed }) => [ + styles.workspace, + (workspace.id === props.selectedId || pressed) && { + backgroundColor: colors.elevated, + }, + ]} + > + + + {workspace.title ?? workspace.name} + + + {workspace.kind === "scratch" ? "Scratch chat" : workspace.name} + + + + )) + )} + + )} + + ))} + + + ); } const styles = StyleSheet.create({ sidebar: { backgroundColor: colors.background }, + browser: { flex: 1, minHeight: 0 }, + phoneBrowser: { flexDirection: "column-reverse" }, + workspaceList: { flex: 1 }, + searchDock: { + flexDirection: "row", + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + width: "100%", + maxWidth: 760, + alignSelf: "center", + }, + createButton: { + minHeight: 48, + paddingHorizontal: spacing.lg, + borderRadius: radii.pill, + backgroundColor: colors.accent, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.sm, + }, + createLabel: { ...typography.body, color: colors.background, fontWeight: "600" }, toolbar: { minHeight: 60, paddingHorizontal: spacing.lg, @@ -215,8 +277,10 @@ const styles = StyleSheet.create({ }, title: { ...typography.header, color: colors.bright, fontSize: 20, letterSpacing: -0.4 }, search: { + flex: 1, + minWidth: 0, backgroundColor: colors.panel, - borderRadius: radii.control, + borderRadius: radii.pill, minHeight: 46, flexDirection: "row", alignItems: "center", diff --git a/packages/mobile/src/theme.ts b/packages/mobile/src/theme.ts index 802dbde6b89..62f5213daf5 100644 --- a/packages/mobile/src/theme.ts +++ b/packages/mobile/src/theme.ts @@ -24,6 +24,8 @@ export const colors = { scrim: "hsla(240, 3%, 3%, 0.58)", }; +export const WIDE_LAYOUT_MIN_WIDTH = 900; + export const spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24, xxxl: 32 }; export const radii = { control: 12, card: 16, sheet: 24, pill: 999 }; // TextInput does not inherit Text typography on web; share the native system face explicitly. diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index ecbe58b5914..65d45b9a960 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6986,7 +6986,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website.", "", - "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Search models directly in the model picker by name, provider, or alias. The grouped list follows model visibility in Settings and includes models available through configured gateway routes; the current selection remains visible even if subsequently hidden. Use Effort to adjust thinking. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", + "It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Phone workspace search and creation stay in a bottom dock, while wide layouts keep their controls above the list. Conversation headers show project and server context with grouped navigation actions. Drafts and unsent model choices survive returning to the list. The composer stays compact when empty and unfocused, expands for writing, and stays at the bottom with mode/model controls directly above it. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Search models directly in the model picker by name, provider, or alias. The grouped list follows model visibility in Settings and includes models available through configured gateway routes; the current selection remains visible even if subsequently hidden. Use Effort to adjust thinking. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.", "", "## Connect to a server", "", From 816282403d34c3051cb917850353741e5231c866 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 08:15:17 +0000 Subject: [PATCH 15/84] =?UTF-8?q?=F0=9F=A4=96=20fix:=20support=20native=20?= =?UTF-8?q?abort=20signals=20in=20mobile=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Native's abort-controller and Expo's static AbortSignal patch do not provide throwIfAborted, which oRPC invokes before sending the initial authenticated request. Add the missing native compatibility method without replacing existing implementations or bypassing cancellation. Reproduce the generic connection error with RN's actual abort implementation and a real WebSocket/oRPC server, then run the transport/auth/cancellation suite in an isolated native-global subprocess. Import the package implementation explicitly because Bun aliases its bare name to the host's modern controller. Validated mobile tests, web/iOS exports, and static checks. Physical-device confirmation remains pending. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$564.47`_ --- packages/mobile/bun.lock | 1 + packages/mobile/package.json | 1 + packages/mobile/src/api.test.ts | 17 +++++++++++ packages/mobile/src/nativeTestPlatform.ts | 17 +++++++++++ packages/mobile/src/nativeTransport.test.ts | 33 +++++++++++++++++++++ packages/mobile/src/polyfills.native.ts | 12 ++++++++ 6 files changed, 81 insertions(+) create mode 100644 packages/mobile/src/nativeTestPlatform.ts create mode 100644 packages/mobile/src/nativeTransport.test.ts diff --git a/packages/mobile/bun.lock b/packages/mobile/bun.lock index 317867eaa87..b4eaea96ce2 100644 --- a/packages/mobile/bun.lock +++ b/packages/mobile/bun.lock @@ -30,6 +30,7 @@ "@types/http-proxy": "^1.17.16", "@types/react": "~19.2.2", "@types/react-dom": "19.2.3", + "abort-controller": "3.0.0", "happy-dom": "20.0.10", "http-proxy": "^1.18.1", "typescript": "~6.0.3", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 51ff9538006..fb2131e0fc1 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -32,6 +32,7 @@ "@types/http-proxy": "^1.17.16", "@types/react": "~19.2.2", "@types/react-dom": "19.2.3", + "abort-controller": "3.0.0", "happy-dom": "20.0.10", "http-proxy": "^1.18.1", "typescript": "~6.0.3" diff --git a/packages/mobile/src/api.test.ts b/packages/mobile/src/api.test.ts index a2e733fb72b..901954f9b85 100644 --- a/packages/mobile/src/api.test.ts +++ b/packages/mobile/src/api.test.ts @@ -90,6 +90,23 @@ async function serverFixture(stallProbe = false) { } describe("mobile WebSocket connection", () => { + test("runtime signals check cancellation and preserve a propagated abort reason", () => { + const controller = new AbortController(); + expect(() => controller.signal.throwIfAborted()).not.toThrow(); + controller.abort(); + expect(() => controller.signal.throwIfAborted()).toThrow(); + + const combined = AbortSignal.any([controller.signal]); + expect(combined.reason).toBeDefined(); + let thrown: unknown; + try { + combined.throwIfAborted(); + } catch (cause) { + thrown = cause; + } + expect(thrown).toBe(combined.reason); + }); + test("authenticates unary probe and streams actual oRPC events through a proxy prefix", async () => { await using server = await serverFixture(); const connection = await connect(`${server.endpoint}/`, server.token); diff --git a/packages/mobile/src/nativeTestPlatform.ts b/packages/mobile/src/nativeTestPlatform.ts new file mode 100644 index 00000000000..b89a1f0e05a --- /dev/null +++ b/packages/mobile/src/nativeTestPlatform.ts @@ -0,0 +1,17 @@ +import { + AbortController as NativeAbortController, + AbortSignal as NativeAbortSignal, +} from "abort-controller/dist/abort-controller"; +import { installAbortSignalPatch } from "expo/src/winter/AbortSignal"; + +// Bun aliases the bare package name to its built-in; use the installed implementation above. +if ("throwIfAborted" in NativeAbortSignal.prototype) { + throw new Error("Native transport tests require React Native's legacy AbortSignal."); +} + +// Match RN's setUpXHR plus Expo's winter patch, rather than Bun's newer AbortSignal. +Object.assign(globalThis, { + AbortController: NativeAbortController, + AbortSignal: NativeAbortSignal, +}); +installAbortSignalPatch(globalThis.AbortSignal); diff --git a/packages/mobile/src/nativeTransport.test.ts b/packages/mobile/src/nativeTransport.test.ts new file mode 100644 index 00000000000..37d7e132973 --- /dev/null +++ b/packages/mobile/src/nativeTransport.test.ts @@ -0,0 +1,33 @@ +import { test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +// Exercise the real transport with native globals without contaminating the other suites. +test("transport connects and cancels with React Native's abort signals", async () => { + const child = Bun.spawn( + [ + process.execPath, + "test", + "--preload", + "./src/nativeTestPlatform.ts", + "--preload", + "./src/polyfills.native.ts", + "./src/api.test.ts", + ], + { + cwd: fileURLToPath(new URL("../", import.meta.url)), + stdout: "pipe", + stderr: "pipe", + } + ); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) + throw new Error(`Native transport tests failed (${code}):\n${stdout}\n${stderr}`); + } finally { + if (child.exitCode === null) child.kill(); + } +}, 30_000); diff --git a/packages/mobile/src/polyfills.native.ts b/packages/mobile/src/polyfills.native.ts index deb4a3e4da4..5b3955d022c 100644 --- a/packages/mobile/src/polyfills.native.ts +++ b/packages/mobile/src/polyfills.native.ts @@ -1,5 +1,17 @@ import { ReadableStream, TransformStream, WritableStream } from "web-streams-polyfill"; +// RN's abort-controller lacks this method even after Expo's patch. oRPC calls it +// before sending the first request, so an otherwise healthy native connection fails. +if (typeof AbortSignal.prototype.throwIfAborted !== "function") { + AbortSignal.prototype.throwIfAborted = function (this: AbortSignal) { + if (this.aborted) { + throw "reason" in this + ? this.reason + : new DOMException("The operation was aborted.", "AbortError"); + } + }; +} + // oRPC uses Web Streams for its peer transport; Hermes is not a browser runtime. if (typeof globalThis.ReadableStream === "undefined") { Object.assign(globalThis, { ReadableStream, TransformStream, WritableStream }); From 3d8ea5a92afe96c3c86babfe1667ed387c355812 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 12:32:32 +0000 Subject: [PATCH 16/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20align=20wor?= =?UTF-8?q?kspace=20and=20transcript=20presentation=20with=20desktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use desktop's top-level workspace selector before counting and searching. Share the adjacent-part display projection so persisted stream chunks do not become separate reasoning blocks or paragraphs, without altering authoritative history or tool boundaries. Show the effective reasoning effort beside the model and a compact context ring using desktop token calculations and the latest step's usage. Context respects compaction/reset boundaries, authoritative completion, replay, and deletion rather than accumulating billing totals. Verified red/green native-web behavior regressions, live usage/replay/reset tests, mobile checks, production web export, desktop workspace-filter tests, and root static checks. Native keyboard correction follows separately. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$704.34`_ --- .../mobile/src/components/ContextUsage.tsx | 61 +++++++++++ packages/mobile/src/components/Message.tsx | 4 +- packages/mobile/src/contextUsage.test.ts | 100 ++++++++++++++++++ packages/mobile/src/contextUsage.ts | 27 +++++ .../mobile/src/screens/ConversationScreen.tsx | 26 +++++ packages/mobile/src/screens/Navigator.tsx | 4 +- .../mobile/src/screens/formTestPlatform.ts | 4 + .../mobile/src/screens/forms.behavior.tsx | 90 ++++++++++++++++ packages/mobile/src/transcript.ts | 11 ++ .../utils/messages/displayedMessageBuilder.ts | 63 +---------- .../utils/messages/mergeAdjacentParts.ts | 61 +++++++++++ 11 files changed, 389 insertions(+), 62 deletions(-) create mode 100644 packages/mobile/src/components/ContextUsage.tsx create mode 100644 packages/mobile/src/contextUsage.test.ts create mode 100644 packages/mobile/src/contextUsage.ts create mode 100644 src/common/utils/messages/mergeAdjacentParts.ts diff --git a/packages/mobile/src/components/ContextUsage.tsx b/packages/mobile/src/components/ContextUsage.tsx new file mode 100644 index 00000000000..5cce134b8dd --- /dev/null +++ b/packages/mobile/src/components/ContextUsage.tsx @@ -0,0 +1,61 @@ +import { StyleSheet, Text, View } from "react-native"; +import Svg, { Circle } from "react-native-svg"; +import type { TokenMeterData } from "../../../../src/common/utils/tokens/tokenMeterUtils"; +import { formatTokens } from "../../../../src/common/utils/tokens/tokenMeterUtils"; +import { colors, fontFamily } from "../theme"; + +const circumference = 2 * Math.PI * 14; + +export function ContextUsage(props: { data: TokenMeterData }) { + const known = props.data.maxTokens != null; + const percentage = Math.max(0, Math.min(100, props.data.totalPercentage)); + const value = known ? `${Math.round(props.data.totalPercentage)}%` : "—"; + const detail = known + ? `${value}, ${formatTokens(props.data.totalTokens)} of ${formatTokens(props.data.maxTokens!)} tokens` + : props.data.totalTokens > 0 + ? `${formatTokens(props.data.totalTokens)} tokens; context limit unknown` + : "No context usage reported yet"; + return ( + + + + {known && ( + + )} + + + {value} + + + ); +} + +const styles = StyleSheet.create({ + meter: { width: 34, height: 34, flexShrink: 0, alignItems: "center", justifyContent: "center" }, + value: { + position: "absolute", + fontFamily, + fontSize: 9, + fontVariant: ["tabular-nums"], + color: colors.muted, + }, +}); diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index e7f016dbb7f..d916d2f3347 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -4,6 +4,7 @@ import { Brain, ChevronDown, ChevronRight, File, Pause, Wrench } from "lucide-re import type { MuxMessage, MuxToolPart } from "../../../../src/common/types/message"; import { Button, Field, Notice, Sheet } from "./Controls"; import { Markdown } from "./Markdown"; +import { mergeAdjacentParts } from "../../../../src/common/utils/messages/mergeAdjacentParts"; import { colors, layout, mono, radii, spacing, typography } from "../theme"; export function Message(props: { @@ -21,7 +22,8 @@ export function Message(props: { return ( {props.message.role === "system" && System} - {props.message.parts.map((part, index) => { + {/* Persisted snapshots retain wire chunks; project the same contiguous display runs as desktop. */} + {mergeAdjacentParts(props.message.parts).map((part, index) => { switch (part.type) { case "text": return ; diff --git a/packages/mobile/src/contextUsage.test.ts b/packages/mobile/src/contextUsage.test.ts new file mode 100644 index 00000000000..7bc83ae88c7 --- /dev/null +++ b/packages/mobile/src/contextUsage.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test"; +import type { MuxMessage, WorkspaceChatMessage } from "./transcript"; +import { applyChatEvent, createTranscriptState } from "./transcript"; +import { getContextUsage } from "./contextUsage"; +import { calculateTokenMeterData } from "../../../src/common/utils/tokens/tokenMeterUtils"; + +const usage = { + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + cachedInputTokens: 40, + reasoningTokens: 5, +}; +const row: MuxMessage = { + id: "a", + role: "assistant", + parts: [], + metadata: { model: "test:model", historySequence: 1, contextUsage: usage }, +}; +const start: Extract = { + type: "stream-start", + workspaceId: "w", + messageId: "b", + model: "test:model", + historySequence: 2, + startTime: 1, +}; +const delta: Extract = { + type: "usage-delta", + workspaceId: "w", + messageId: "b", + usage, + cumulativeUsage: { ...usage, inputTokens: 900, totalTokens: 920 }, +}; +const meter = (messages: MuxMessage[]) => + calculateTokenMeterData(getContextUsage(messages, "test:model"), "test:model", false); + +test("context uses the latest step, not cumulative usage, and survives finish/replay", () => { + let state = applyChatEvent(createTranscriptState(), { type: "message", ...row }); + state = applyChatEvent(state, start); + expect(meter(state.messages).totalTokens).toBe(120); + expect(applyChatEvent(state, { ...delta, messageId: "stale" })).toBe(state); + state = applyChatEvent(state, { ...delta, usage: { ...usage, inputTokens: 200 } }); + expect(meter(state.messages).totalTokens).toBe(220); + state = applyChatEvent(state, { + type: "stream-end", + workspaceId: "w", + messageId: "b", + parts: [], + metadata: { model: "test:model", contextUsage: { ...usage, inputTokens: 250 } }, + }); + expect(meter(state.messages).totalTokens).toBe(270); + const replay = state.messages.reduce( + (current, message) => applyChatEvent(current, { type: "message", ...message }), + createTranscriptState() + ); + expect(meter(replay.messages)).toEqual(meter(state.messages)); + expect( + meter(applyChatEvent(state, { type: "delete", historySequences: [1, 2] }).messages).totalTokens + ).toBe(0); +}); + +test("context does not resurrect pre-boundary or compacted usage, but keeps the boundary estimate", () => { + const boundary: MuxMessage = { + id: "boundary", + role: "assistant", + parts: [], + metadata: { compactionBoundary: true, compacted: "user", compactionEpoch: 1 }, + }; + expect(getContextUsage([row, boundary], "test:model")).toBeUndefined(); + expect( + getContextUsage( + [row, { ...boundary, metadata: { contextBoundaryKind: "reset" } }], + "test:model" + ) + ).toBeUndefined(); + expect( + meter([ + row, + { + ...boundary, + metadata: { ...boundary.metadata, contextUsage: { ...usage, inputTokens: 50 } }, + }, + ]).totalTokens + ).toBe(70); + expect( + meter([ + row, + { + ...row, + id: "compacted", + metadata: { + ...row.metadata, + compacted: true, + contextUsage: { ...usage, inputTokens: 500 }, + }, + }, + ]).totalTokens + ).toBe(120); +}); diff --git a/packages/mobile/src/contextUsage.ts b/packages/mobile/src/contextUsage.ts new file mode 100644 index 00000000000..5b250d6d975 --- /dev/null +++ b/packages/mobile/src/contextUsage.ts @@ -0,0 +1,27 @@ +import type { MuxMessage } from "../../../src/common/types/message"; +import { getContextBoundaryKind } from "../../../src/common/utils/messages/compactionBoundary"; +import { createDisplayUsage } from "../../../src/common/utils/tokens/displayUsage"; + +export function getContextUsage(messages: MuxMessage[], model: string) { + // Like desktop, use the latest request in the current epoch, never session totals. + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + const boundary = getContextBoundaryKind(message); + if (boundary === "reset") return undefined; + const metadata = message.metadata; + if ( + message.role === "assistant" && + (boundary || !metadata?.compacted) && + metadata?.contextUsage + ) { + return createDisplayUsage( + metadata.contextUsage, + metadata.model ?? model, + boundary ? undefined : (metadata.contextProviderMetadata ?? metadata.providerMetadata), + metadata.metadataModel + ); + } + if (boundary) return undefined; + } + return undefined; +} diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index a7222118dcc..1b63e65a30b 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -25,6 +25,9 @@ import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/wor import type { MuxMessage } from "../../../../src/common/types/message"; import { Button, IconButton, Loading, Notice } from "../components/Controls"; import { Message } from "../components/Message"; +import { ContextUsage } from "../components/ContextUsage"; +import { getContextUsage } from "../contextUsage"; +import { calculateTokenMeterData } from "../../../../src/common/utils/tokens/tokenMeterUtils"; import { useConversation } from "../useConversation"; import { linkedAbortController } from "../useConnection"; import { modelName, resolveSettings } from "../settings"; @@ -87,6 +90,13 @@ export function ConversationScreen(props: { const agentId = props.workspace.agentId ?? "exec"; const options = props.selection ?? (settings ? resolveSettings(props.workspace, settings, agentId) : null); + const context = calculateTokenMeterData( + getContextUsage(transcript.messages, options?.model ?? "unknown"), + options?.model ?? "unknown", + false, + false, + settings?.providers + ); const ready = props.connected && !props.signal.aborted && transcript.caughtUp && !error && settings !== null; const running = ready && transcript.streaming; @@ -318,9 +328,15 @@ export function ConversationScreen(props: { {options?.model ? modelName(options.model) : "Model"} + {options && ( + + {(options.thinkingLevel ?? DEFAULT_THINKING_LEVEL).toUpperCase()} + + )} + "ClipboardList", "Code2", "Folder", + "Search", + "Settings", + "SquarePen", + "Plus", "MessageSquare", "KeyRound", "LogOut", diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index c0e221de32a..6595ac8bd7d 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -13,6 +13,7 @@ import { Message } from "../components/Message"; import { Markdown } from "../components/Markdown"; import { CreateWorkspace } from "./CreateWorkspace"; import { ModelSettings } from "./ModelSettings"; +import { Navigator } from "./Navigator"; import { SettingsScreen } from "./SettingsScreen"; import type { ChatSettings, SettingsData } from "../settings"; @@ -27,6 +28,47 @@ const workspace: FrontendWorkspaceMetadata = { runtimeConfig: { type: "local" }, }; +test("navigator counts and searches roots, not their agents, and preserves orphan access", () => { + const child: FrontendWorkspaceMetadata = { + ...workspace, + id: "child", + title: "Architecture Scout", + parentWorkspaceId: workspace.id, + taskStatus: "reported", + }; + const props = { + projects: [], + workspaces: [workspace, child], + loading: false, + error: null, + onRetry: () => {}, + onSelect: () => {}, + onCreate: () => {}, + onSettings: () => {}, + }; + const view = render(); + expect(view.getByRole("button", { name: "project, 1 workspaces" })).toBeDefined(); + expect(view.queryByRole("button", { name: child.title })).toBeNull(); + const search = view.getByRole("textbox", { name: "Search workspaces" }); + fireEvent.change(search, { target: { value: "Scout" } }); + expect(view.getByText("No matching workspaces")).toBeDefined(); + fireEvent.change(search, { target: { value: "" } }); + + // A live metadata update must not promote a running child into a peer row. + view.rerender( + + ); + expect(view.queryByRole("button", { name: child.title })).toBeNull(); + // Preserve desktop's orphan recovery when the parent is no longer in the list. + view.rerender(); + expect(view.getByRole("button", { name: child.title })).toBeDefined(); + // User-created roots can have agent-like names; hierarchy, not naming, controls visibility. + view.rerender( + + ); + expect(view.getByRole("button", { name: "agent_explore_user" })).toBeDefined(); +}); + test("empty assistant history is explained without mislabeling a live or completed response", () => { const props = { canAnswer: false, onAnswer: async () => {} }; const message = { id: "empty", role: "assistant" as const, parts: [] }; @@ -44,6 +86,54 @@ test("empty assistant history is explained without mislabeling a live or complet expect(view.queryByText("Interrupted")).toBeNull(); }); +test("raw completion and replay chunks render as adjacent runs without crossing tools", () => { + const parts: MuxMessage["parts"] = [ + { type: "reasoning", text: "Just a sim" }, + { type: "reasoning", text: "ple greeting, not t" }, + { type: "reasoning", text: "ools needed." }, + { type: "text", text: "Hey" }, + { type: "text", text: "! What can I help with?" }, + ]; + const original = structuredClone(parts); + const message: MuxMessage = { id: "reply", role: "assistant", parts }; + const props = { canAnswer: false, onAnswer: async () => {} }; + const view = render(); + expect(view.getAllByRole("button", { name: "Reasoning" })).toHaveLength(1); + fireEvent.click(view.getByRole("button", { name: "Reasoning" })); + expect(view.getByText("Just a simple greeting, not tools needed.")).toBeDefined(); + expect(view.getByText("Hey! What can I help with?")).toBeDefined(); + expect(parts).toEqual(original); + + view.rerender(); + expect(view.getAllByRole("button", { name: "Reasoning" })).toHaveLength(1); + expect(view.getByText("Interrupted")).toBeDefined(); + view.rerender( + + ); + expect(view.getAllByRole("button", { name: "Reasoning" })).toHaveLength(2); + expect(view.getByText("Hey! What can I help with?")).toBeDefined(); + expect(view.getByText("After the tool.")).toBeDefined(); +}); + test("a field ref focuses the next native input", () => { const next = createRef(); const view = render( diff --git a/packages/mobile/src/transcript.ts b/packages/mobile/src/transcript.ts index 849af1c2025..ffd2c13975b 100644 --- a/packages/mobile/src/transcript.ts +++ b/packages/mobile/src/transcript.ts @@ -182,6 +182,17 @@ export function applyChatEvent( } return { ...message, parts }; }); + case "usage-delta": + if (state.streamingMessageId !== event.messageId) return state; + // Context is the latest step, not cumulative billing across tool iterations. + return updateMessage(state, event.messageId, (message) => ({ + ...message, + metadata: { + ...message.metadata, + contextUsage: event.usage, + contextProviderMetadata: event.providerMetadata, + }, + })); case "tool-call-start": case "tool-call-end": return updateMessage(state, event.messageId, (message) => applyTool(message, event)); diff --git a/src/browser/utils/messages/displayedMessageBuilder.ts b/src/browser/utils/messages/displayedMessageBuilder.ts index 4a39660c8c0..e85ebbf7070 100644 --- a/src/browser/utils/messages/displayedMessageBuilder.ts +++ b/src/browser/utils/messages/displayedMessageBuilder.ts @@ -29,6 +29,9 @@ import { import { isPlainObject } from "@/common/utils/isPlainObject"; import { isRefusalFinishReason } from "@/common/utils/messages/refusalFinishReason"; import { isDynamicToolPart, type DynamicToolPart } from "@/common/types/toolParts"; +import { mergeAdjacentParts } from "@/common/utils/messages/mergeAdjacentParts"; + +export { mergeAdjacentParts } from "@/common/utils/messages/mergeAdjacentParts"; /** * Check if a tool result indicates success (for tools that return { success: boolean }) @@ -78,66 +81,6 @@ export function normalizeMessageRouteProvider(message: MuxMessage): MuxMessage { }; } -/** - * Merge adjacent text/reasoning parts using array accumulation + join(). - * Avoids O(n²) string allocations from repeated concatenation. - * Tool parts are preserved as-is between merged text/reasoning runs. - */ -export function mergeAdjacentParts(parts: MuxMessage["parts"]): MuxMessage["parts"] { - if (parts.length <= 1) return parts; - - const merged: MuxMessage["parts"] = []; - let pendingTexts: string[] = []; - let pendingTextTimestamp: number | undefined; - let pendingReasonings: string[] = []; - let pendingReasoningTimestamp: number | undefined; - - const flushText = () => { - if (pendingTexts.length > 0) { - merged.push({ - type: "text", - text: pendingTexts.join(""), - timestamp: pendingTextTimestamp, - }); - pendingTexts = []; - pendingTextTimestamp = undefined; - } - }; - - const flushReasoning = () => { - if (pendingReasonings.length > 0) { - merged.push({ - type: "reasoning", - text: pendingReasonings.join(""), - timestamp: pendingReasoningTimestamp, - }); - pendingReasonings = []; - pendingReasoningTimestamp = undefined; - } - }; - - for (const part of parts) { - if (part.type === "text") { - flushReasoning(); - pendingTexts.push(part.text); - pendingTextTimestamp ??= part.timestamp; - } else if (part.type === "reasoning") { - flushText(); - pendingReasonings.push(part.text); - pendingReasoningTimestamp ??= part.timestamp; - } else { - // Tool part - flush and keep as-is - flushText(); - flushReasoning(); - merged.push(part); - } - } - flushText(); - flushReasoning(); - - return merged; -} - export function getTextPartContent(parts: ReadonlyArray): string { const content: string[] = []; for (const part of parts) { diff --git a/src/common/utils/messages/mergeAdjacentParts.ts b/src/common/utils/messages/mergeAdjacentParts.ts new file mode 100644 index 00000000000..56cbb78d32f --- /dev/null +++ b/src/common/utils/messages/mergeAdjacentParts.ts @@ -0,0 +1,61 @@ +import type { MuxMessage } from "@/common/types/message"; + +/** + * Merge adjacent text/reasoning parts using array accumulation + join(). + * Avoids O(n²) string allocations from repeated concatenation. + * Tool parts are preserved as-is between merged text/reasoning runs. + */ +export function mergeAdjacentParts(parts: MuxMessage["parts"]): MuxMessage["parts"] { + if (parts.length <= 1) return parts; + + const merged: MuxMessage["parts"] = []; + let pendingTexts: string[] = []; + let pendingTextTimestamp: number | undefined; + let pendingReasonings: string[] = []; + let pendingReasoningTimestamp: number | undefined; + + const flushText = () => { + if (pendingTexts.length > 0) { + merged.push({ + type: "text", + text: pendingTexts.join(""), + timestamp: pendingTextTimestamp, + }); + pendingTexts = []; + pendingTextTimestamp = undefined; + } + }; + + const flushReasoning = () => { + if (pendingReasonings.length > 0) { + merged.push({ + type: "reasoning", + text: pendingReasonings.join(""), + timestamp: pendingReasoningTimestamp, + }); + pendingReasonings = []; + pendingReasoningTimestamp = undefined; + } + }; + + for (const part of parts) { + if (part.type === "text") { + flushReasoning(); + pendingTexts.push(part.text); + pendingTextTimestamp ??= part.timestamp; + } else if (part.type === "reasoning") { + flushText(); + pendingReasonings.push(part.text); + pendingReasoningTimestamp ??= part.timestamp; + } else { + // Tool part - flush and keep as-is + flushText(); + flushReasoning(); + merged.push(part); + } + } + flushText(); + flushReasoning(); + + return merged; +} From a2e0171dd6c46d51b38da9955179fb73823a3975 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 12:28:34 +0000 Subject: [PATCH 17/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20measure=20n?= =?UTF-8?q?ative=20keyboard=20avoidance=20in=20window=20coordinates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the SDK-pinned keyboard controller for all four native keyboard boundaries, preserving core web behavior and safe-area layout. Measure window offsets rather than guessing header or sheet insets. Verify the installed overlap algorithm against safe-area and page-sheet geometry, keyboard-height changes, dismissal, and disabled avoidance. Native device positioning still requires iPhone validation. Validation: make -j1 mobile-check (71 passed, 1 existing integration skip); Expo iOS and web exports; web source maps exclude the native keyboard dependencies. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- packages/mobile/App.tsx | 19 ++-- packages/mobile/bun.lock | 89 ++++++++++++++++++- packages/mobile/package.json | 3 + packages/mobile/src/components/Controls.tsx | 2 +- .../mobile/src/components/Keyboard.native.tsx | 10 +++ .../mobile/src/components/Keyboard.test.ts | 28 ++++++ packages/mobile/src/components/Keyboard.tsx | 2 + .../src/components/keyboard.behavior.tsx | 38 ++++++++ .../src/components/keyboardTestController.ts | 9 ++ .../src/components/keyboardTestRuntime.tsx | 73 +++++++++++++++ packages/mobile/src/screens/ConnectScreen.tsx | 3 +- .../mobile/src/screens/ConversationScreen.tsx | 12 +-- packages/mobile/src/screens/Navigator.tsx | 3 +- 13 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 packages/mobile/src/components/Keyboard.native.tsx create mode 100644 packages/mobile/src/components/Keyboard.test.ts create mode 100644 packages/mobile/src/components/Keyboard.tsx create mode 100644 packages/mobile/src/components/keyboard.behavior.tsx create mode 100644 packages/mobile/src/components/keyboardTestController.ts create mode 100644 packages/mobile/src/components/keyboardTestRuntime.tsx diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx index 61d6a5cefd2..b22234de701 100644 --- a/packages/mobile/App.tsx +++ b/packages/mobile/App.tsx @@ -15,6 +15,7 @@ import { CreateWorkspace } from "./src/screens/CreateWorkspace"; import { ChangesScreen } from "./src/screens/ChangesScreen"; import { SettingsScreen } from "./src/screens/SettingsScreen"; import { Button, Header, Loading, Notice } from "./src/components/Controls"; +import { KeyboardProvider } from "./src/components/Keyboard"; import { useProjects } from "./src/useProjects"; import { useConnection } from "./src/useConnection"; import { colors, layout, WIDE_LAYOUT_MIN_WIDTH } from "./src/theme"; @@ -51,14 +52,16 @@ export default function App() { const [connection, setConnection] = useState(null); return ( - - {connection ? ( - setConnection(null)} /> - ) : ( - - - - )} + + + {connection ? ( + setConnection(null)} /> + ) : ( + + + + )} + ); } diff --git a/packages/mobile/bun.lock b/packages/mobile/bun.lock index b4eaea96ce2..a1d78cd6a18 100644 --- a/packages/mobile/bun.lock +++ b/packages/mobile/bun.lock @@ -15,10 +15,13 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", + "react-native-keyboard-controller": "1.21.9", + "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", + "react-native-worklets": "0.10.1", "web-streams-polyfill": "4.2.0", }, "devDependencies": { @@ -106,6 +109,8 @@ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ=="], + "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA=="], "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w=="], @@ -152,10 +157,20 @@ "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA=="], + "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.8", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg=="], + "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q=="], + "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg=="], + + "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA=="], + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA=="], @@ -264,6 +279,8 @@ "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.86.3", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.86.3" } }, "sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ=="], + "@react-native/babel-preset": ["@react-native/babel-preset@0.87.1", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@react-native/babel-plugin-codegen": "0.87.1", "babel-plugin-syntax-hermes-parser": "0.36.1", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-EN1oo8IqsJgq++na/buq6hYsdZJY5A8+URohSA620eGz5a+322WOAcBuvcYZTeovYwR2NegvpPk5h+QVk8iUMw=="], + "@react-native/codegen": ["@react-native/codegen@0.86.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.36.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA=="], "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.86.3", "", { "dependencies": { "@react-native/dev-middleware": "0.86.3", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.3", "metro-config": "^0.84.3", "metro-core": "^0.84.3", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "0.86.3" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-qSDL9LQc5mZSZPNczT95WU9YQuPzxBklgON9vLhhqfI0yWIwKInqFx88dQ/uiEXBtf0yossthaQIqA4Ml6bF6g=="], @@ -278,6 +295,10 @@ "@react-native/js-polyfills": ["@react-native/js-polyfills@0.86.3", "", {}, "sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg=="], + "@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.87.1", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.87.1", "hermes-parser": "0.36.1", "nullthrows": "^1.1.1" } }, "sha512-9qPN7DIBA2ldpTVeEObRuIf0ihF9iphBR17rOyuFu8UnhbzHpoLDHCqnjnOm6P21TY8GU6JgaqmD4rhtUCOFeQ=="], + + "@react-native/metro-config": ["@react-native/metro-config@0.87.1", "", { "dependencies": { "@react-native/js-polyfills": "0.87.1", "@react-native/metro-babel-transformer": "0.87.1", "metro-config": "^0.87.0", "metro-runtime": "^0.87.0" } }, "sha512-oHgFFZpWDElw/ZO7YmNsTproNtHL0Ty/PWAR7Lf7YjuT7HuY11RyihQ4Kh/YseepHi/9vMBe8QNFqJCB8eMNZg=="], + "@react-native/normalize-colors": ["@react-native/normalize-colors@0.86.3", "", {}, "sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg=="], "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.86.3", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.86.3" }, "optionalPeers": ["@types/react"] }, "sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw=="], @@ -608,6 +629,8 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inline-style-prefixer": ["inline-style-prefixer@7.0.1", "", { "dependencies": { "css-in-js-utils": "^3.1.0" } }, "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw=="], @@ -688,7 +711,7 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react-native": ["lucide-react-native@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-native": "*", "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" } }, "sha512-8Ny42mSzJNW1obB08LtZxq0uQ9Kh0VeZvpbYPBRNUDWX/8uNva6JOapFRjS0cKpHz1Vz6cqMqkhLD9WMcx33jA=="], @@ -822,6 +845,8 @@ "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], + "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], + "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -838,6 +863,12 @@ "react-native": ["react-native@0.86.3", "", { "dependencies": { "@react-native/assets-registry": "0.86.3", "@react-native/codegen": "0.86.3", "@react-native/community-cli-plugin": "0.86.3", "@react-native/gradle-plugin": "0.86.3", "@react-native/js-polyfills": "0.86.3", "@react-native/normalize-colors": "0.86.3", "@react-native/virtualized-lists": "0.86.3", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.36.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.17", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.86.3", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q=="], + "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="], + + "react-native-keyboard-controller": ["react-native-keyboard-controller@1.21.9", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-reanimated": ">=3.0.0" } }, "sha512-+TkkFldht4+AXBQeDy1hLE7iqiW8/NkY/ekhcFsKIiRdI9qC5JDzx0TfAg1iYZB2IeOXppmURIy2jFCUjOcV1w=="], + + "react-native-reanimated": ["react-native-reanimated@4.5.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.83 - 0.86", "react-native-worklets": "0.10.x" } }, "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA=="], + "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], "react-native-screens": ["react-native-screens@4.26.2", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A=="], @@ -846,6 +877,8 @@ "react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="], + "react-native-worklets": ["react-native-worklets@0.10.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", "@babel/plugin-transform-classes": "^7.28.6", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-optional-chaining": "^7.28.6", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.28.5", "@babel/types": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.4" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.83 - 0.86" } }, "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA=="], + "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -1042,8 +1075,6 @@ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1062,8 +1093,18 @@ "@expo/ws-tunnel/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.87.1", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.87.1" } }, "sha512-DfnyLAHG7jH4pqeID7ib6AeD6gG8T+KvM3c/w/yEa5ONkfRcHvTC8JMIRdhUqs4ruA+8xbYcNVQzewYRCPm1Xw=="], + + "@react-native/babel-preset/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.1", "", { "dependencies": { "hermes-parser": "0.36.1" } }, "sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA=="], + "@react-native/codegen/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + "@react-native/metro-config/@react-native/js-polyfills": ["@react-native/js-polyfills@0.87.1", "", {}, "sha512-L9yKwEJCq7e8RjhthMUht2CP/CPnBR134h9xHkJ0XOO15QOWBAOqSZltTZG/wiokidqePvK+exMsRinuFLsN9w=="], + + "@react-native/metro-config/metro-config": ["metro-config@0.87.0", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.87.0", "metro-cache": "0.87.0", "metro-core": "0.87.0", "metro-runtime": "0.87.0" } }, "sha512-yZ9QAIzWH9MxwrzwRlX/CBGRWOT14l7klSDYg8hdtSdnoUs5A7MQRdHE2KB9iHVzGQW5wgWM5aXJswNeWbSQPA=="], + + "@react-native/metro-config/metro-runtime": ["metro-runtime@0.87.0", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-XsXZkgEwI0ZMYSBfvOMAbenzwa60XlObXJ27g6/Khgrz9ESbiBbAsd7hR62G2jRBYOhGAzG61Gk22dpRJj/mdw=="], + "@react-navigation/core/react-is": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], @@ -1130,6 +1171,8 @@ "ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], "react-native/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], @@ -1162,8 +1205,16 @@ "@expo/metro-runtime/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.87.1", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.36.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-qbaqEdlUfj2vRgvWTpMoNgHnEqAhAYJLUrpGkb0WC9n0kdtqUvgigpz4bDktZwolM9BemXwgGFgyiAnbs3t0xw=="], + "@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], + "@react-native/metro-config/metro-config/metro": ["metro@0.87.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.36.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.87.0", "metro-cache": "0.87.0", "metro-cache-key": "0.87.0", "metro-config": "0.87.0", "metro-core": "0.87.0", "metro-file-map": "0.87.0", "metro-resolver": "0.87.0", "metro-runtime": "0.87.0", "metro-source-map": "0.87.0", "metro-symbolicate": "0.87.0", "metro-transform-plugins": "0.87.0", "metro-transform-worker": "0.87.0", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-fRqFhSzQhLNQSCvJFeuRzBRXAOOKXf1O8d2cvmMtG6yFR0jCllQ7vBsXoLP18yuqtf+N1XwWXTPF11eWy9q6dQ=="], + + "@react-native/metro-config/metro-config/metro-cache": ["metro-cache@0.87.0", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.87.0" } }, "sha512-146vS1BMSKcp99jddOhFBfHwzUEWN35NrsnSJDF2sQQ0ZT5OsBcOjd574PM233TWEZISRJ5DOK+vokD+1ubx+w=="], + + "@react-native/metro-config/metro-config/metro-core": ["metro-core@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.87.0" } }, "sha512-yW57+pCOHRC/CJZ99GA2PTd+30dORwDAjUPRCokj91IWW5In9Jwtt2FB5wACrGO8P0GHTyVHdTwDNyZsNndUbA=="], + "babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1204,6 +1255,30 @@ "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "@react-native/metro-config/metro-config/metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "@react-native/metro-config/metro-config/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "@react-native/metro-config/metro-config/metro/metro-babel-transformer": ["metro-babel-transformer@0.87.0", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.36.1", "metro-cache-key": "0.87.0", "nullthrows": "^1.1.1" } }, "sha512-IEn1K1FyY4J1sA5y6zqDjf2OkfmpTEqhZOeP6MJX8HepSW0cuHGw1m8bYOdv2adkG3XUE9dtM0csUs0gP/Xa5w=="], + + "@react-native/metro-config/metro-config/metro/metro-cache-key": ["metro-cache-key@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Q+MPt6jl0zQogr4Q02WaJK6HY+GtE5A0nzj8kIV1Owgrx6OMNvm6scPTr1SM/R4LpCE8EH/Y5qfbXQ84GHTr0Q=="], + + "@react-native/metro-config/metro-config/metro/metro-file-map": ["metro-file-map@0.87.0", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-Dc57t8jsINwA90bbVlqaeDlxf1rVGgj5SmOEnOMbaHNUM/HCYYTJxPV8SRdOBwh7qTz/biO9vaQvBTjRBgbbsg=="], + + "@react-native/metro-config/metro-config/metro/metro-resolver": ["metro-resolver@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg=="], + + "@react-native/metro-config/metro-config/metro/metro-source-map": ["metro-source-map@0.87.0", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.87.0", "nullthrows": "^1.1.1", "ob1": "0.87.0", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-31BrYqu1c2co93rF1LN9Pw+7g+BrfDyxJkNQWrYm+pfA/+eVYVumF7tFMHbXLePLmHfhvSgVvbK6su1Oyiw1ng=="], + + "@react-native/metro-config/metro-config/metro/metro-symbolicate": ["metro-symbolicate@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.87.0", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-uOpTxAXu74N+RSujUZ78L6gjI6bDdnz6XuW+AIUNuubZDEQPIpaX0StzIb0GMeQWP6zfHOHwFrWPP5Iquu1GXw=="], + + "@react-native/metro-config/metro-config/metro/metro-transform-plugins": ["metro-transform-plugins@0.87.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-i8keUe9+BaSwMuQM26DGheElCpTtflAKIrSwJAm8ZsgDb50RAUQus+e6zt2suaJXJ1OxZa7vqgtqoZxdniM6Fw=="], + + "@react-native/metro-config/metro-config/metro/metro-transform-worker": ["metro-transform-worker@0.87.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.87.0", "metro-babel-transformer": "0.87.0", "metro-cache": "0.87.0", "metro-cache-key": "0.87.0", "metro-minify-terser": "0.87.0", "metro-source-map": "0.87.0", "metro-transform-plugins": "0.87.0", "nullthrows": "^1.1.1" } }, "sha512-YftLzNJxCTYxEN5k4AzR8KYwiENTEuz30L+4QeoMrtDd+U8mDThZg/ArR3JVRd8LaikwPOjVAS5SP3xPJN0AaA=="], + + "@react-native/metro-config/metro-config/metro/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "@react-native/metro-config/metro-config/metro-core/metro-resolver": ["metro-resolver@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg=="], + "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -1212,6 +1287,14 @@ "ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "@react-native/metro-config/metro-config/metro/accepts/negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "@react-native/metro-config/metro-config/metro/metro-source-map/ob1": ["ob1@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-8Q8sKCiUwsxgSmjDtVWyRgmxsgeJXXam3oQH6Id8ADfNaJMV6GZKyeAl8+pGVVdgwAZabfk5+aExle7AP/nZiA=="], + + "@react-native/metro-config/metro-config/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-tPa0O983PDutFu3LXbArRH5NduogcKrvW6fs9VHhksTKUA1iqDyoD1ZSj/Me52xJ6T9/9pOwIVyj9ulKc/zMkg=="], + + "@react-native/metro-config/metro-config/metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/packages/mobile/package.json b/packages/mobile/package.json index fb2131e0fc1..a636a23b472 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -17,10 +17,13 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", + "react-native-keyboard-controller": "1.21.9", + "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", + "react-native-worklets": "0.10.1", "web-streams-polyfill": "4.2.0" }, "devDependencies": { diff --git a/packages/mobile/src/components/Controls.tsx b/packages/mobile/src/components/Controls.tsx index 2afa8759716..555dec7c740 100644 --- a/packages/mobile/src/components/Controls.tsx +++ b/packages/mobile/src/components/Controls.tsx @@ -2,7 +2,6 @@ import { forwardRef } from "react"; import type { ReactNode } from "react"; import { ActivityIndicator, - KeyboardAvoidingView, Modal, Platform, Pressable, @@ -16,6 +15,7 @@ import type { TextInputProps } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { AlertCircle, ChevronLeft, Info, TriangleAlert, X } from "lucide-react-native"; import type { LucideIcon } from "lucide-react-native"; +import { KeyboardAvoidingView } from "./Keyboard"; import { colors, layout, radii, spacing, typography } from "../theme"; export function IconButton(props: { diff --git a/packages/mobile/src/components/Keyboard.native.tsx b/packages/mobile/src/components/Keyboard.native.tsx new file mode 100644 index 00000000000..e250bc5f77f --- /dev/null +++ b/packages/mobile/src/components/Keyboard.native.tsx @@ -0,0 +1,10 @@ +import type { KeyboardAvoidingViewProps } from "react-native"; +import { KeyboardAvoidingView as NativeKeyboardAvoidingView } from "react-native-keyboard-controller"; + +export { KeyboardProvider } from "react-native-keyboard-controller"; + +export function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { + // Safe areas and page sheets put local layout and keyboard frames in different + // coordinate spaces. Native measures in window coordinates; web keeps core behavior. + return ; +} diff --git a/packages/mobile/src/components/Keyboard.test.ts b/packages/mobile/src/components/Keyboard.test.ts new file mode 100644 index 00000000000..eefaa15ba35 --- /dev/null +++ b/packages/mobile/src/components/Keyboard.test.ts @@ -0,0 +1,28 @@ +import { test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +// Native host mocks must not leak into the mobile transport or form suites. +test("native keyboard geometry", async () => { + const child = Bun.spawn( + [ + process.execPath, + "test", + "--preload", + "./src/components/keyboardTestRuntime.tsx", + "--preload", + "./src/components/keyboardTestController.ts", + "./src/components/keyboard.behavior.tsx", + ], + { cwd: fileURLToPath(new URL("../../", import.meta.url)), stdout: "pipe", stderr: "pipe" } + ); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) throw new Error(`Keyboard tests failed (${code}):\n${stdout}\n${stderr}`); + } finally { + if (child.exitCode === null) child.kill(); + } +}, 30_000); diff --git a/packages/mobile/src/components/Keyboard.tsx b/packages/mobile/src/components/Keyboard.tsx new file mode 100644 index 00000000000..5ee20eef74e --- /dev/null +++ b/packages/mobile/src/components/Keyboard.tsx @@ -0,0 +1,2 @@ +export { Fragment as KeyboardProvider } from "react"; +export { KeyboardAvoidingView } from "react-native"; diff --git a/packages/mobile/src/components/keyboard.behavior.tsx b/packages/mobile/src/components/keyboard.behavior.tsx new file mode 100644 index 00000000000..dc491d72109 --- /dev/null +++ b/packages/mobile/src/components/keyboard.behavior.tsx @@ -0,0 +1,38 @@ +import { afterEach, expect, test } from "bun:test"; +import { act, cleanup, render } from "@testing-library/react"; +import type { LayoutChangeEvent } from "react-native"; +import { KeyboardAvoidingView } from "./Keyboard.native"; +import { geometry } from "./keyboardTestRuntime"; + +afterEach(cleanup); + +for (const windowY of [59, 110]) { + test(`keeps the input above predictive text at window offset ${windowY}`, async () => { + geometry.windowY = windowY; + geometry.progress = 1; + geometry.keyboardHeight = 336; + const height = geometry.screenHeight - windowY - 34; + const view = render(); + expect(geometry.onLayout).not.toBeNull(); + await act(async () => { + await geometry.onLayout!({ + nativeEvent: { layout: { x: 0, y: 0, width: 390, height } }, + } as LayoutChangeEvent); + }); + const padding = () => + Number.parseFloat((view.container.firstChild as HTMLElement).style.paddingBottom); + for (const keyboardHeight of [336, 380]) { + geometry.keyboardHeight = keyboardHeight; + view.rerender(); + // A bottommost input meets the keyboard top, without adding the header + // inside the avoiding view or double-counting the bottom safe area. + expect(windowY + height - padding()).toBe(geometry.screenHeight - keyboardHeight); + } + geometry.progress = 0; + view.rerender(); + expect(padding()).toBe(0); + geometry.progress = 1; + view.rerender(); + expect((view.container.firstChild as HTMLElement).style.paddingBottom).toBe(""); + }); +} diff --git a/packages/mobile/src/components/keyboardTestController.ts b/packages/mobile/src/components/keyboardTestController.ts new file mode 100644 index 00000000000..cf50913a42f --- /dev/null +++ b/packages/mobile/src/components/keyboardTestController.ts @@ -0,0 +1,9 @@ +import { mock } from "bun:test"; +import { Fragment } from "react"; +import KeyboardAvoidingView from "../../node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView"; + +// The preceding preload replaces native hosts; keep the installed KAV itself real. +mock.module("react-native-keyboard-controller", () => ({ + KeyboardProvider: Fragment, + KeyboardAvoidingView, +})); diff --git a/packages/mobile/src/components/keyboardTestRuntime.tsx b/packages/mobile/src/components/keyboardTestRuntime.tsx new file mode 100644 index 00000000000..2b7eaa00968 --- /dev/null +++ b/packages/mobile/src/components/keyboardTestRuntime.tsx @@ -0,0 +1,73 @@ +import "../testDom"; +import { mock } from "bun:test"; +import { useRef } from "react"; +import type { ReactNode, Ref } from "react"; +import type { LayoutChangeEvent } from "react-native"; + +export const geometry = { + screenHeight: 844, + windowY: 59, + keyboardHeight: 336, + progress: 1, + onLayout: null as ((event: LayoutChangeEvent) => Promise) | null, +}; + +function View(props: { + ref?: Ref; + children?: ReactNode; + style?: object[]; + onLayout?: typeof geometry.onLayout; +}) { + geometry.onLayout = props.onLayout ?? null; + return ( +
+ {props.children} +
+ ); +} + +// Run the installed KAV calculation, replacing only native measurements and the +// UI-thread host. Animation getters stay live as keyboard geometry changes. +mock.module("react-native", () => ({ View })); +mock.module("react-native-reanimated", () => ({ + default: { View }, + interpolate: (value: number, _input: number[], output: number[]) => value * output[1], + runOnUI: (fn: (value: unknown) => void) => fn, + useAnimatedStyle: (fn: () => object) => fn(), + useDerivedValue: (fn: () => unknown) => ({ + get value() { + return fn(); + }, + }), + useSharedValue: (value: unknown) => useRef({ value }).current, +})); +const controller = "../../node_modules/react-native-keyboard-controller/src/"; +mock.module(`${controller}bindings`, () => ({ + KeyboardControllerNative: { + viewPositionInWindow: async () => ({ x: 0, y: geometry.windowY }), + }, +})); +mock.module(`${controller}hooks`, () => ({ + useWindowDimensions: () => ({ height: geometry.screenHeight }), +})); +mock.module(`${controller}utils/findNodeHandle`, () => ({ findNodeHandle: () => 1 })); +mock.module(`${controller}components/KeyboardAvoidingView/hooks`, () => ({ + useKeyboardAnimation: () => ({ + heightWhenOpened: { + get value() { + return geometry.keyboardHeight; + }, + }, + progress: { + get value() { + return geometry.progress; + }, + }, + isClosed: { + get value() { + return geometry.progress === 0; + }, + }, + }), + useTranslateAnimation: () => ({ translate: { value: 0 }, padding: { value: 0 } }), +})); diff --git a/packages/mobile/src/screens/ConnectScreen.tsx b/packages/mobile/src/screens/ConnectScreen.tsx index fd280977f63..302a56dd690 100644 --- a/packages/mobile/src/screens/ConnectScreen.tsx +++ b/packages/mobile/src/screens/ConnectScreen.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState } from "react"; -import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import { ArrowRight, Eye, EyeOff, ShieldCheck } from "lucide-react-native"; import type { TextInput } from "react-native"; import { connect } from "../connection"; import { isInsecureEndpoint } from "../endpoint"; import { loadCredentials, saveCredentials } from "../credentials"; import { Button, Field, IconButton, Loading, Notice } from "../components/Controls"; +import { KeyboardAvoidingView } from "../components/Keyboard"; import { colors, layout, spacing, typography } from "../theme"; export type Connection = Awaited>; diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 1b63e65a30b..27c638a7ff4 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -1,15 +1,6 @@ import { useEffect, useRef, useState } from "react"; import type { SetStateAction } from "react"; -import { - FlatList, - KeyboardAvoidingView, - Platform, - Pressable, - StyleSheet, - Text, - TextInput, - View, -} from "react-native"; +import { FlatList, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { ArrowDown, ArrowUp, @@ -24,6 +15,7 @@ import type { MobileClient } from "../api"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; import type { MuxMessage } from "../../../../src/common/types/message"; import { Button, IconButton, Loading, Notice } from "../components/Controls"; +import { KeyboardAvoidingView } from "../components/Keyboard"; import { Message } from "../components/Message"; import { ContextUsage } from "../components/ContextUsage"; import { getContextUsage } from "../contextUsage"; diff --git a/packages/mobile/src/screens/Navigator.tsx b/packages/mobile/src/screens/Navigator.tsx index ebefa20a821..64a0004ee24 100644 --- a/packages/mobile/src/screens/Navigator.tsx +++ b/packages/mobile/src/screens/Navigator.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; import { - KeyboardAvoidingView, Platform, Pressable, RefreshControl, @@ -26,6 +25,7 @@ import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/wor import type { Projects } from "../useProjects"; import { excludeSubAgentRows } from "../../../../src/browser/utils/ui/workspaceFiltering"; import { Button, IconButton, Loading, Notice } from "../components/Controls"; +import { KeyboardAvoidingView } from "../components/Keyboard"; import { colors, fontFamily, @@ -79,6 +79,7 @@ export function Navigator(props: { return ( From 66f68541ae03ffe082ed8a6cc38b840004dd2275 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 12:40:59 +0000 Subject: [PATCH 18/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20expose=20co?= =?UTF-8?q?ntext=20progress=20to=20native=20and=20web=20accessibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use React Native's cross-platform ARIA aliases because RN Web does not forward accessibilityValue. Add a rendering regression for known, over-limit, and unknown percentages. Keep the browser model-preservation assertion on textContent for both its baseline and comparison now that effort is a separate text node. Validation: mobile checks, web/iOS exports, and repository static checks pass after these dogfood fixes. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$713.72`_ --- packages/mobile/e2e/native-ux.spec.ts | 4 ++-- packages/mobile/src/components/ContextUsage.tsx | 7 ++++--- packages/mobile/src/screens/formTestPlatform.ts | 1 + packages/mobile/src/screens/forms.behavior.tsx | 12 ++++++++++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/mobile/e2e/native-ux.spec.ts b/packages/mobile/e2e/native-ux.spec.ts index 82ed873de82..b46df1a9e8f 100644 --- a/packages/mobile/e2e/native-ux.spec.ts +++ b/packages/mobile/e2e/native-ux.spec.ts @@ -130,10 +130,10 @@ test("native stack preserves drafts and sheets keep their actions reachable", as await page.getByRole("radio", { name: "High", exact: true }).click(); await expect(page.getByRole("button", { name: /^Effort/ })).toContainText("High"); await page.getByRole("button", { name: "Close", exact: true }).click(); - const modelBeforeModeChange = await chooseModel.innerText(); + const modelBeforeModeChange = await chooseModel.textContent(); await page.getByRole("button", { name: "Choose mode", exact: true }).click(); await page.getByRole("radio", { name: /^Plan/ }).click(); - await expect(chooseModel).toHaveText(modelBeforeModeChange); + await expect.poll(() => chooseModel.textContent()).toBe(modelBeforeModeChange); await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveValue(draft); await expect(page.getByRole("button", { name: "Choose mode", exact: true })).toContainText( "Plan" diff --git a/packages/mobile/src/components/ContextUsage.tsx b/packages/mobile/src/components/ContextUsage.tsx index 5cce134b8dd..3001ae810a1 100644 --- a/packages/mobile/src/components/ContextUsage.tsx +++ b/packages/mobile/src/components/ContextUsage.tsx @@ -20,9 +20,10 @@ export function ContextUsage(props: { data: TokenMeterData }) { accessible accessibilityRole="progressbar" accessibilityLabel="Context usage" - accessibilityValue={ - known ? { min: 0, max: 100, now: percentage, text: detail } : { text: detail } - } + aria-valuemin={known ? 0 : undefined} + aria-valuemax={known ? 100 : undefined} + aria-valuenow={known ? percentage : undefined} + aria-valuetext={detail} style={styles.meter} > diff --git a/packages/mobile/src/screens/formTestPlatform.ts b/packages/mobile/src/screens/formTestPlatform.ts index 9f12f6ba6a5..8d58ad48dfd 100644 --- a/packages/mobile/src/screens/formTestPlatform.ts +++ b/packages/mobile/src/screens/formTestPlatform.ts @@ -8,6 +8,7 @@ import * as NativeWeb from "react-native-web"; mock.module("react-native", () => NativeWeb); mock.module("react-native-safe-area-context", () => ({ SafeAreaView: NativeWeb.View })); const icon = () => null; +mock.module("react-native-svg", () => ({ default: NativeWeb.View, Circle: icon })); mock.module("lucide-react-native", () => Object.fromEntries( [ diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 6595ac8bd7d..b8a78a11e73 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -10,6 +10,7 @@ import type { MobileClient } from "../api"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; import { Button, Field, Sheet } from "../components/Controls"; import { Message } from "../components/Message"; +import { ContextUsage } from "../components/ContextUsage"; import { Markdown } from "../components/Markdown"; import { CreateWorkspace } from "./CreateWorkspace"; import { ModelSettings } from "./ModelSettings"; @@ -28,6 +29,17 @@ const workspace: FrontendWorkspaceMetadata = { runtimeConfig: { type: "local" }, }; +test("context meter exposes measured progress without inventing an unknown percentage", () => { + const data = { segments: [], totalTokens: 200_000, maxTokens: 1_000_000, totalPercentage: 20 }; + const view = render(); + expect(view.getByRole("progressbar").getAttribute("aria-valuenow")).toBe("20"); + view.rerender(); + expect(view.getByRole("progressbar").getAttribute("aria-valuenow")).toBe("100"); + view.rerender(); + expect(view.getByRole("progressbar").getAttribute("aria-valuenow")).toBeNull(); + expect(view.getByRole("progressbar").getAttribute("aria-valuetext")).toBeTruthy(); +}); + test("navigator counts and searches roots, not their agents, and preserves orphan access", () => { const child: FrontendWorkspaceMetadata = { ...workspace, From 557c8126540358d97cacc248f1aba0a5f0464f39 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 13:33:29 +0000 Subject: [PATCH 19/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20fetch=20cha?= =?UTF-8?q?nges=20across=20repositories=20and=20validate=20mobile=20in=20C?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one bulk getProjectDiffs operation using validated per-project repo-root execution and fixed git argv with external diff/textconv disabled. Preserve checkout errors and truncation per repository so a clean primary cannot mask secondary changes. Render all results in the mobile changes view. Run mobile-check after the required workflow's root static checks because mobile's isolated graph is excluded there. Document matching client/server revisions for the evolving API. Validation: reproduced the old primary-only false-clean UI; mobile checks and root static checks pass; bulk routing/scratch/truncation/error regressions pass; actionlint/zizmor pass. Real disposable two-repository RPC checks also found secondary changes and did not execute the configured external diff helper. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$757.08`_ --- .github/workflows/pr.yml | 3 + docs/integrations/mobile-app.md | 2 + packages/mobile/src/screens/ChangesScreen.tsx | 189 +++++++++--------- .../mobile/src/screens/formTestPlatform.ts | 3 + .../mobile/src/screens/forms.behavior.tsx | 57 +++++- src/common/orpc/schemas/api.ts | 16 ++ src/node/orpc/router.ts | 6 + src/node/services/workspaceService.test.ts | 82 ++++++++ src/node/services/workspaceService.ts | 36 ++++ 9 files changed, 302 insertions(+), 92 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 33d0f97becb..b8108a0225f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -136,6 +136,9 @@ jobs: - run: make -j static-check-full env: MUX_ESLINT_CONCURRENCY: 4 + # Mobile has an isolated dependency graph and is excluded from the root checks. + - name: Validate mobile companion + run: make -j1 mobile-check flake-hash-check: name: Flake Hash Check diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index aa61a6fd639..ef6ee38c138 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -11,6 +11,8 @@ It connects to your existing Xum server for projects, workspace creation, conver Enable [server access](/config/server-access), or start `xum server`. Use a trusted HTTPS endpoint accessible from the device and enter the server's bearer token separately. Include any reverse-proxy path prefix in the endpoint. A Coder login page or another upstream authentication layer may require additional network access; the Xum token does not authenticate to that outer layer. +During development, run the mobile client and server from the same branch/revision. Their shared API contract evolves together; for example, the multi-repository changes view requires the server's bulk project-diff endpoint. + The token grants access to the server, including its code-execution capabilities. Treat it like a password. Native builds save connection details in device secure storage. The web preview keeps them in memory only; refreshing requires entering them again. Disconnect clears the saved native connection. Public endpoints require HTTPS. Literal private LAN and loopback HTTP addresses are accepted for development, with a plaintext-token warning. Mobile platform transport policies may still restrict cleartext networking; prefer HTTPS on devices. A phone's `localhost` refers to the phone, not your development computer. diff --git a/packages/mobile/src/screens/ChangesScreen.tsx b/packages/mobile/src/screens/ChangesScreen.tsx index d1d47b29861..ac8857757dc 100644 --- a/packages/mobile/src/screens/ChangesScreen.tsx +++ b/packages/mobile/src/screens/ChangesScreen.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { ScrollView, StyleSheet, Text, View } from "react-native"; import { CheckCircle2, FileCode, RefreshCw } from "lucide-react-native"; import type { MobileClient } from "../api"; +import type { ProjectGitDiffResult } from "../../../../src/common/orpc/schemas/api"; import { Header, IconButton, Loading, Notice } from "../components/Controls"; import { colors, layout, mono, radii, spacing, typography } from "../theme"; import { linkedAbortController } from "../useConnection"; @@ -13,8 +14,7 @@ export function ChangesScreen(props: { onReconnect: () => Promise; onBack: () => void; }) { - const [output, setOutput] = useState(null); - const [note, setNote] = useState(null); + const [projects, setProjects] = useState(null); const [error, setError] = useState(null); const [generation, setGeneration] = useState(0); function retry() { @@ -22,44 +22,16 @@ export function ChangesScreen(props: { } useEffect(() => { const controller = linkedAbortController(props.signal); - setOutput(null); + setProjects(null); setError(null); - setNote(null); if (controller.signal.aborted) { setError("Reconnect to load changes."); return; } - // Fixed argv prevents branch/file names from becoming shell code. Disable external - // diff/textconv hooks: this view only reads tracked worktree changes against HEAD. props.client.workspace - .executeBash( - { - workspaceId: props.workspaceId, - script: "", - command: "git", - args: [ - "--no-pager", - "diff", - "--no-ext-diff", - "--no-textconv", - "--no-color", - "HEAD", - "--", - ], - options: { timeout_secs: 20, cwdMode: "repo-root" }, - }, - { signal: controller.signal } - ) - .then((result) => { - if (controller.signal.aborted) return; - if (!result.success) throw new Error(result.error); - if (!result.data.success) throw new Error(result.data.error); - setOutput(result.data.output); - setNote( - result.data.truncated - ? "The server truncated this diff. Review the full changes on desktop before making decisions." - : (result.data.note ?? null) - ); + .getProjectDiffs({ workspaceId: props.workspaceId }, { signal: controller.signal }) + .then((results) => { + if (!controller.signal.aborted) setProjects(results); }) .catch((cause: unknown) => { if (!controller.signal.aborted) @@ -67,7 +39,12 @@ export function ChangesScreen(props: { }); return () => controller.abort(); }, [props.client, props.workspaceId, props.signal, generation]); - const files = output?.split(/(?=^diff --git )/m).filter(Boolean) ?? []; + const clean = + projects != null && + projects.length > 0 && + projects.every( + (project) => project.success && !project.data.truncated && project.data.diff === "" + ); return (
{error && {error}} - {output === null && !error && } - {note && {note}} - {output === "" && ( + {projects === null && !error && } + {projects?.length === 0 && ( + This workspace has no Git repositories. + )} + {clean && ( No uncommitted changes @@ -89,59 +68,17 @@ export function ChangesScreen(props: { )} - {files.length > 0 && ( - - {files.length} changed {files.length === 1 ? "file" : "files"} - - )} - {files.map((file, index) => { - const lines = file.trimEnd().split("\n"); - const filename = - lines - .find((line) => line.startsWith("+++ ")) - ?.slice(4) - .replace(/^b\//, "") ?? lines[0].replace(/^diff --git /, ""); - const content = lines.filter((line) => !/^(diff --git |index |--- |\+\+\+ )/.test(line)); - const additions = content.filter((line) => line.startsWith("+")).length; - const deletions = content.filter((line) => line.startsWith("-")).length; - return ( - - - - - {filename} - - +{additions} - −{deletions} - - - - {content.map((line, lineIndex) => ( - - {line || " "} - - ))} - - - - ); - })} - {output !== null && ( + {projects?.map((project) => ( + + {project.projectName} + {project.success ? ( + + ) : ( + {project.error} + )} + + ))} + {projects !== null && ( Staged and unstaged tracked files, compared with HEAD. Untracked files and committed changes aren’t shown. @@ -152,6 +89,76 @@ export function ChangesScreen(props: { ); } +function ProjectDiff(props: { data: Extract["data"] }) { + const files = props.data.diff.split(/(?=^diff --git )/m).filter(Boolean); + return ( + + {props.data.truncated ? ( + + The server truncated this repository's diff. Review the full changes on desktop. + + ) : ( + props.data.note && {props.data.note} + )} + {!props.data.truncated && props.data.diff === "" && ( + No tracked changes in this repository. + )} + {files.length > 0 && ( + + {files.length} changed {files.length === 1 ? "file" : "files"} + + )} + {files.map((file, index) => { + const lines = file.trimEnd().split("\n"); + const filename = + lines + .find((line) => line.startsWith("+++ ")) + ?.slice(4) + .replace(/^b\//, "") ?? lines[0].replace(/^diff --git /, ""); + const content = lines.filter((line) => !/^(diff --git |index |--- |\+\+\+ )/.test(line)); + const additions = content.filter((line) => line.startsWith("+")).length; + const deletions = content.filter((line) => line.startsWith("-")).length; + return ( + + + + + {filename} + + +{additions} + −{deletions} + + + + {content.map((line, lineIndex) => ( + + {line || " "} + + ))} + + + + ); + })} + + ); +} + const styles = StyleSheet.create({ content: { padding: spacing.xl, diff --git a/packages/mobile/src/screens/formTestPlatform.ts b/packages/mobile/src/screens/formTestPlatform.ts index 8d58ad48dfd..2685e4ecca5 100644 --- a/packages/mobile/src/screens/formTestPlatform.ts +++ b/packages/mobile/src/screens/formTestPlatform.ts @@ -36,6 +36,9 @@ mock.module("lucide-react-native", () => "ShieldCheck", "Brain", "File", + "FileCode", + "CheckCircle2", + "RefreshCw", "Pause", "Wrench", ].map((name) => [name, icon]) diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index b8a78a11e73..e63f000b183 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -1,7 +1,7 @@ import "./formTestPlatform"; import { afterEach, expect, test } from "bun:test"; import { createRef, useState } from "react"; -import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { createORPCClient } from "@orpc/client"; import { View } from "react-native"; import type { TextInput } from "react-native"; @@ -13,6 +13,7 @@ import { Message } from "../components/Message"; import { ContextUsage } from "../components/ContextUsage"; import { Markdown } from "../components/Markdown"; import { CreateWorkspace } from "./CreateWorkspace"; +import { ChangesScreen } from "./ChangesScreen"; import { ModelSettings } from "./ModelSettings"; import { Navigator } from "./Navigator"; import { SettingsScreen } from "./SettingsScreen"; @@ -29,6 +30,60 @@ const workspace: FrontendWorkspaceMetadata = { runtimeConfig: { type: "local" }, }; +test("changes include secondary repositories in one request and do not hide failed checkouts", async () => { + const calls: string[] = []; + const client = createORPCClient({ + call: async (path) => { + const method = path.join("."); + calls.push(method); + if (method === "workspace.getProjectDiffs") + return [ + { + projectName: "Primary", + projectPath: "/primary", + success: true, + data: { diff: "", truncated: false }, + }, + { + projectName: "Secondary", + projectPath: "/secondary", + success: true, + data: { + diff: "diff --git a/secondary.ts b/secondary.ts\n--- a/secondary.ts\n+++ b/secondary.ts\n@@ -1 +1 @@\n-old\n+new\n", + truncated: false, + }, + }, + { + projectName: "Offline", + projectPath: "/offline", + success: false, + error: "Checkout unavailable", + }, + ]; + // The old unqualified request sees a clean primary repository and misses the rest. + if (method === "workspace.executeBash") + return { + success: true, + data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, + }; + throw new Error(`Unexpected procedure: ${method}`); + }, + }); + const view = render( + {}} + onBack={() => {}} + /> + ); + await waitFor(() => expect(view.getByText("secondary.ts")).toBeDefined()); + expect(calls).toEqual(["workspace.getProjectDiffs"]); + expect(view.getByText("Checkout unavailable")).toBeDefined(); + expect(view.queryByText("No uncommitted changes")).toBeNull(); +}); + test("context meter exposes measured progress without inventing an unknown percentage", () => { const data = { segments: [], totalTokens: 200_000, maxTokens: 1_000_000, totalPercentage: 20 }; const view = render(); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index b48391603a9..3b0c80a2191 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -180,6 +180,18 @@ export const ProjectGitStatusResultSchema = z.object({ export type ProjectGitStatusResult = z.infer; +export const ProjectGitDiffResultSchema = ProjectRefSchema.and( + ResultSchema( + z.object({ + diff: z.string(), + truncated: z.boolean(), + note: z.string().optional(), + }), + z.string() + ) +); +export type ProjectGitDiffResult = z.infer; + export const BackgroundProcessMonitorInfoSchema = z.object({ filter: z.string(), filter_exclude: z.boolean(), @@ -1490,6 +1502,10 @@ export const workspace = { input: z.object({ workspaceIds: z.array(z.string()) }), output: z.record(z.string(), z.enum(["running", "stopped", "unknown", "unsupported"])), }, + getProjectDiffs: { + input: z.object({ workspaceId: z.string() }), + output: z.array(ProjectGitDiffResultSchema), + }, getProjectGitStatuses: { input: z.object({ workspaceId: z.string(), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 1a6dcc4ea99..190d54e3cf6 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1620,6 +1620,12 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => context.workspaceService.getRuntimeStatuses(input.workspaceIds) ), + getProjectDiffs: t + .input(schemas.workspace.getProjectDiffs.input) + .output(schemas.workspace.getProjectDiffs.output) + .handler(async ({ context, input }) => + context.workspaceService.getProjectDiffs(input.workspaceId) + ), getProjectGitStatuses: t .input(schemas.workspace.getProjectGitStatuses.input) .output(schemas.workspace.getProjectGitStatuses.output) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 503de37c67b..6f8c090830f 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -11738,6 +11738,88 @@ describe("WorkspaceService getProjectGitStatuses", () => { return { workspaceService, executeBashMock, getWorkspaceMetadataMock }; } + test("getProjectDiffs includes secondary repositories and keeps per-repo failures visible", async () => { + const metadata: WorkspaceMetadata = { + id: "ws-multi-diff", + name: "feature", + projectName: "primary", + projectPath: "/primary", + runtimeConfig: { type: "local" }, + projects: [ + { projectPath: "/primary", projectName: "primary" }, + { projectPath: "/secondary", projectName: "secondary" }, + { projectPath: "/offline", projectName: "offline" }, + ], + }; + const { workspaceService, executeBashMock } = createServiceHarness({ + metadata, + executeBashImpl: (_id, _script, options) => { + if (options?.repoRootProjectPath === "/offline") + return Promise.reject(new Error("Repository unavailable")); + return Promise.resolve( + bashOk(options?.repoRootProjectPath === "/secondary" ? "secondary diff" : "") + ); + }, + }); + const results = await workspaceService.getProjectDiffs(metadata.id); + expect(results).toEqual([ + { ...metadata.projects![0], success: true, data: { diff: "", truncated: false } }, + { + ...metadata.projects![1], + success: true, + data: { diff: "secondary diff", truncated: false }, + }, + { ...metadata.projects![2], success: false, error: "Repository unavailable" }, + ]); + expect(executeBashMock).toHaveBeenCalledTimes(3); + expect(executeBashMock).toHaveBeenNthCalledWith( + 2, + metadata.id, + "", + { cwdMode: "repo-root", repoRootProjectPath: "/secondary", timeout_secs: 20 }, + "git", + ["--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "HEAD", "--"] + ); + }); + + test("getProjectDiffs skips scratch chats and preserves truncation for single repositories", async () => { + const metadata: WorkspaceMetadata = { + id: "ws-diff", + name: "feature", + projectName: "project", + projectPath: "/project", + runtimeConfig: { type: "local" }, + }; + const harness = createServiceHarness({ + metadata, + executeBashImpl: () => + Promise.resolve( + Ok({ + success: true, + output: "partial diff", + exitCode: 0, + wall_duration_ms: 1, + truncated: { reason: "output limit", totalLines: 100 }, + note: "limit", + }) + ), + }); + expect(await harness.workspaceService.getProjectDiffs(metadata.id)).toEqual([ + { + projectPath: "/project", + projectName: "project", + success: true, + data: { diff: "partial diff", truncated: true, note: "limit" }, + }, + ]); + const scratch = createServiceHarness({ + metadata: { ...metadata, kind: "scratch" }, + executeBashImpl: () => Promise.reject(new Error("git should not run")), + }); + expect(await scratch.workspaceService.getProjectDiffs(metadata.id)).toEqual([]); + expect(scratch.executeBashMock).not.toHaveBeenCalled(); + }); + test("returns no entries for scratch workspaces without invoking git", async () => { const metadata: WorkspaceMetadata = { kind: "scratch", diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 819ee590112..25b574584b2 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -288,6 +288,7 @@ import type { ArchivePreflightResult, ArchiveWorkspaceResult, BackgroundProcessInfo, + ProjectGitDiffResult, } from "@/common/orpc/schemas/api"; import type { SessionTimingService } from "@/node/services/sessionTimingService"; import type { SessionUsageService } from "@/node/services/sessionUsageService"; @@ -9139,6 +9140,41 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { return statuses; } + async getProjectDiffs(workspaceId: string): Promise { + assert(workspaceId.trim().length > 0, "getProjectDiffs requires a workspaceId"); + const metadata = await this.aiService.getWorkspaceMetadata(workspaceId); + if (!metadata.success) throw new Error(`Failed to get workspace metadata: ${metadata.error}`); + if (metadata.data.kind === "scratch") return []; + + // Return every repository in one IPC response; a failed checkout must not look clean. + return Promise.all( + getProjects(metadata.data).map(async (project): Promise => { + try { + const result = await this.executeBash( + workspaceId, + "", + { cwdMode: "repo-root", repoRootProjectPath: project.projectPath, timeout_secs: 20 }, + "git", + ["--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "HEAD", "--"] + ); + if (!result.success) return { ...project, ...Err(result.error) }; + if (!result.data.success) return { ...project, ...Err(result.data.error) }; + assert(!("backgroundProcessId" in result.data), "Git diff must finish in the foreground"); + return { + ...project, + ...Ok({ + diff: result.data.output, + truncated: result.data.truncated != null, + note: result.data.note, + }), + }; + } catch (error) { + return { ...project, ...Err(getErrorMessage(error)) }; + } + }) + ); + } + async getProjectGitStatuses( workspaceId: string, baseRef?: string | null From 9ddd05e79130ff6746993789a04d1cbaa5c71cd0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 13:20:28 +0000 Subject: [PATCH 20/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20preserve=20?= =?UTF-8?q?canonical=20AI=20settings=20and=20provider=20preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the shared agent resolver and declared ancestor collector for field-wise mobile defaults, including Off effort and standard workspace reasoning. Carry synced provider privacy/cache/context options into explicit selections and sends, calculate context capacity from those options, and apply OpenAI auth gates only on the actual direct route. Add settings and composer regressions for precedence, cycles, privacy retention, 1M capacity, picker actions, and gateway routing. Validation: make mobile-check (80 pass, 1 optional real-server skip); make mobile-export mobile-export-ios. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- packages/mobile/src/contextUsage.test.ts | 49 +++++- packages/mobile/src/contextUsage.ts | 25 +++ .../mobile/src/screens/ConversationScreen.tsx | 28 ++-- .../mobile/src/screens/formTestPlatform.ts | 4 + .../mobile/src/screens/forms.behavior.tsx | 128 +++++++++++++++- packages/mobile/src/settings.test.ts | 142 +++++++++++++++++- packages/mobile/src/settings.ts | 74 ++++++--- 7 files changed, 409 insertions(+), 41 deletions(-) diff --git a/packages/mobile/src/contextUsage.test.ts b/packages/mobile/src/contextUsage.test.ts index 7bc83ae88c7..fb65c1f83d9 100644 --- a/packages/mobile/src/contextUsage.test.ts +++ b/packages/mobile/src/contextUsage.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import type { MuxMessage, WorkspaceChatMessage } from "./transcript"; import { applyChatEvent, createTranscriptState } from "./transcript"; -import { getContextUsage } from "./contextUsage"; +import { getContextUsage, getContextMeterData } from "./contextUsage"; import { calculateTokenMeterData } from "../../../src/common/utils/tokens/tokenMeterUtils"; const usage = { @@ -98,3 +98,50 @@ test("context does not resurrect pre-boundary or compacted usage, but keeps the ]).totalTokens ).toBe(120); }); + +test("context capacity follows per-model 1M intent without bypassing privacy or capability gates", () => { + const model = "anthropic:claude-sonnet-4-20250514"; + const anthropic = { use1MContextModels: [model] }; + const options = { model, agentId: "exec", providerOptions: { anthropic } }; + // Pin the non-beta limit rather than depending on changing upstream model metadata. + const providers = { + anthropic: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: [{ id: "claude-sonnet-4-20250514", contextWindowTokens: 200_000 }], + }, + }; + const capacity = (settings: Parameters[1]) => + getContextMeterData([row], settings, providers).maxTokens; + expect(capacity(options)).toBe(1_000_000); + expect( + capacity({ + ...options, + providerOptions: { + anthropic: { ...anthropic, disableBetaFeatures: true }, + }, + }) + ).toBe(200_000); + expect( + capacity({ + ...options, + providerOptions: { + anthropic: { use1MContextModels: ["anthropic:another-model"] }, + }, + }) + ).toBe(200_000); + expect( + capacity({ + ...options, + providerOptions: { + anthropic: { use1MContext: true }, + }, + }) + ).toBe(1_000_000); + expect(capacity({ ...options, model: "openai:gpt-4o" })).toBe(128_000); + // Canonical preferences still match a gateway-scoped model selection. + expect(capacity({ ...options, model: "openrouter:anthropic/claude-sonnet-4-20250514" })).toBe( + 1_000_000 + ); +}); diff --git a/packages/mobile/src/contextUsage.ts b/packages/mobile/src/contextUsage.ts index 5b250d6d975..5c12387d0e0 100644 --- a/packages/mobile/src/contextUsage.ts +++ b/packages/mobile/src/contextUsage.ts @@ -1,3 +1,7 @@ +import type { ChatSettings, SettingsData } from "./settings"; +import { normalizeToCanonical, supports1MContext } from "../../../src/common/utils/ai/models"; +import { resolveModelForMetadata } from "../../../src/common/utils/providers/modelEntries"; +import { calculateTokenMeterData } from "../../../src/common/utils/tokens/tokenMeterUtils"; import type { MuxMessage } from "../../../src/common/types/message"; import { getContextBoundaryKind } from "../../../src/common/utils/messages/compactionBoundary"; import { createDisplayUsage } from "../../../src/common/utils/tokens/displayUsage"; @@ -25,3 +29,24 @@ export function getContextUsage(messages: MuxMessage[], model: string) { } return undefined; } + +export function getContextMeterData( + messages: MuxMessage[], + options: ChatSettings | null, + providers?: SettingsData["providers"] +) { + const model = options?.model ?? "unknown"; + const anthropic = options?.providerOptions?.anthropic; + const canonical = normalizeToCanonical(model); + const metadataModel = resolveModelForMetadata(model, providers ?? null); + // Use synced per-model intent, but never advertise beta capacity when ZDR disables it. + const use1M = + supports1MContext(model, providers) && + anthropic?.disableBetaFeatures !== true && + (anthropic?.use1MContext === true || + (anthropic?.use1MContextModels?.some( + (enabled) => enabled === model || enabled === canonical || enabled === metadataModel + ) ?? + false)); + return calculateTokenMeterData(getContextUsage(messages, model), model, use1M, false, providers); +} diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 27c638a7ff4..0d0d70fd332 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -18,15 +18,14 @@ import { Button, IconButton, Loading, Notice } from "../components/Controls"; import { KeyboardAvoidingView } from "../components/Keyboard"; import { Message } from "../components/Message"; import { ContextUsage } from "../components/ContextUsage"; -import { getContextUsage } from "../contextUsage"; -import { calculateTokenMeterData } from "../../../../src/common/utils/tokens/tokenMeterUtils"; +import { getContextMeterData } from "../contextUsage"; import { useConversation } from "../useConversation"; import { linkedAbortController } from "../useConnection"; import { modelName, resolveSettings } from "../settings"; import type { ChatSettings } from "../settings"; import { ModelSettings } from "./ModelSettings"; import { colors, fontFamily, layout, radii, spacing, typography } from "../theme"; -import { DEFAULT_THINKING_LEVEL } from "../../../../src/common/types/thinking"; +import { THINKING_LEVEL_OFF } from "../../../../src/common/types/thinking"; // RN Web reports scrollHeight, which cannot shrink a fixed-height textarea and // can expand hidden stack screens. Let the browser size content; native uses its intrinsic measurement. @@ -80,15 +79,15 @@ export function ConversationScreen(props: { return () => abort.abort(); }, [props.signal]); const agentId = props.workspace.agentId ?? "exec"; - const options = - props.selection ?? (settings ? resolveSettings(props.workspace, settings, agentId) : null); - const context = calculateTokenMeterData( - getContextUsage(transcript.messages, options?.model ?? "unknown"), - options?.model ?? "unknown", - false, - false, - settings?.providers - ); + const options = settings + ? resolveSettings( + props.workspace, + settings, + props.selection?.agentId ?? agentId, + props.selection + ) + : null; + const context = getContextMeterData(transcript.messages, options, settings?.providers); const ready = props.connected && !props.signal.aborted && transcript.caughtUp && !error && settings !== null; const running = ready && transcript.streaming; @@ -106,8 +105,7 @@ export function ConversationScreen(props: { { workspaceId: props.workspace.id, message, - // Persist the effective default alongside the model, like the desktop composer. - options: { ...options, thinkingLevel: options.thinkingLevel ?? DEFAULT_THINKING_LEVEL }, + options, }, { signal } ); @@ -322,7 +320,7 @@ export function ConversationScreen(props: { {options && ( - {(options.thinkingLevel ?? DEFAULT_THINKING_LEVEL).toUpperCase()} + {(options.thinkingLevel ?? THINKING_LEVEL_OFF).toUpperCase()} )} diff --git a/packages/mobile/src/screens/formTestPlatform.ts b/packages/mobile/src/screens/formTestPlatform.ts index 2685e4ecca5..ab189dfcfde 100644 --- a/packages/mobile/src/screens/formTestPlatform.ts +++ b/packages/mobile/src/screens/formTestPlatform.ts @@ -13,6 +13,10 @@ mock.module("lucide-react-native", () => Object.fromEntries( [ "AlertCircle", + "ArrowDown", + "ArrowUp", + "GitCompareArrows", + "Square", "ChevronLeft", "Info", "TriangleAlert", diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index e63f000b183..268743ac1c0 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -15,6 +15,8 @@ import { Markdown } from "../components/Markdown"; import { CreateWorkspace } from "./CreateWorkspace"; import { ChangesScreen } from "./ChangesScreen"; import { ModelSettings } from "./ModelSettings"; +import { ConversationScreen } from "./ConversationScreen"; +import type { WorkspaceChatMessage } from "../transcript"; import { Navigator } from "./Navigator"; import { SettingsScreen } from "./SettingsScreen"; import type { ChatSettings, SettingsData } from "../settings"; @@ -303,7 +305,19 @@ test("workspace creation cannot be dismissed or submitted twice while the server expect(selected).toBe(workspace); }); -const pickerValue: ChatSettings = { agentId: "exec", model: "local:one", thinkingLevel: "high" }; +const pickerValue: ChatSettings = { + agentId: "exec", + model: "local:one", + thinkingLevel: "high", + providerOptions: { + anthropic: { + disableBetaFeatures: true, + cacheTtl: "1h", + use1MContextModels: ["anthropic:claude-sonnet-4-20250514"], + }, + google: { cache: false }, + }, +}; const pickerData: SettingsData = { config: { agentAiDefaults: {}, defaultModel: "local:one", hiddenModels: ["other:hidden"] }, providers: { @@ -355,6 +369,118 @@ function PickerHarness(props: { ); } +test.each([ + { selected: false, disableBetaFeatures: true }, + { selected: true, disableBetaFeatures: true }, + { selected: true, disableBetaFeatures: false }, +])( + "conversation sends resolved effort and synced options matching its context meter: %j", + async (scenario) => { + const model = "anthropic:claude-sonnet-4-20250514"; + const providerOptions = { + anthropic: { + disableBetaFeatures: scenario.disableBetaFeatures, + cacheTtl: "1h" as const, + use1MContextModels: [model], + }, + google: { cache: false }, + }; + const requests: Array[0]> = []; + let eventsController!: ReadableStreamDefaultController; + const events = new ReadableStream({ + start(controller) { + eventsController = controller; + }, + }); + const client = createORPCClient({ + call: async (path, input, request) => { + switch (path.join(".")) { + case "config.getConfig": + return { + agentAiDefaults: {}, + defaultModel: model, + userPreferences: { ai: { providerOptions } }, + }; + case "providers.getConfig": + return { + anthropic: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: [{ id: "claude-sonnet-4-20250514", contextWindowTokens: 200_000 }], + }, + }; + case "agents.list": + return pickerData.agents; + case "workspace.onChat": + request.signal?.addEventListener("abort", () => eventsController.close(), { + once: true, + }); + return events.values(); + case "workspace.sendMessage": + requests.push(input as Parameters[0]); + return { success: true }; + default: + throw new Error(`Unexpected settings call: ${path.join(".")}`); + } + }, + }); + const lifetime = new AbortController(); + const view = render( + {}} + onBack={() => {}} + selection={scenario.selected ? { model, agentId: "plan" } : null} + onSelectionChange={() => {}} + draft="Keep preferences" + onDraftChange={() => {}} + onChanges={() => {}} + onSettings={() => {}} + /> + ); + await act(async () => { + eventsController.enqueue({ + type: "message", + id: "usage", + role: "assistant", + parts: [], + metadata: { + model, + historySequence: 1, + contextUsage: { + inputTokens: 100_000, + outputTokens: 0, + totalTokens: 100_000, + cachedInputTokens: 0, + reasoningTokens: 0, + }, + }, + }); + eventsController.enqueue({ type: "caught-up", hasOlderHistory: false }); + }); + const send = view.getByRole("button", { name: "Send message" }); + await waitFor(() => expect(send.getAttribute("aria-disabled")).not.toBe("true")); + expect(view.getByRole("progressbar").getAttribute("aria-valuenow")).toBe( + scenario.disableBetaFeatures ? "50" : "10" + ); + fireEvent.click(send); + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0].options).toMatchObject({ + model, + agentId: scenario.selected ? "plan" : "exec", + thinkingLevel: "off", + providerOptions, + }); + expect(view.getByText(requests[0].options.thinkingLevel!.toUpperCase())).toBeDefined(); + view.unmount(); + } +); + test("model picks apply directly while keeping mode and effort", () => { const changes: ChatSettings[] = []; let closed = 0; diff --git a/packages/mobile/src/settings.test.ts b/packages/mobile/src/settings.test.ts index 26bf455524c..96a2052848d 100644 --- a/packages/mobile/src/settings.test.ts +++ b/packages/mobile/src/settings.test.ts @@ -120,7 +120,147 @@ describe("mobile model settings", () => { agentId: "exec", model: "workspace:exec", thinkingLevel: "high", - reasoningMode: undefined, + reasoningMode: "standard", + providerOptions: undefined, }); }); + test("unset effort is Off, including an explicit model with Default effort", () => { + const config = data(); + expect(resolveSettings({}, config, "exec").thinkingLevel).toBe("off"); + expect( + resolveSettings({}, config, "exec", { model: "openai:gpt-4o", agentId: "exec" }).thinkingLevel + ).toBe("off"); + }); + test("declared agent bases inherit each field without importing subagent defaults", () => { + const config = data(); + const descriptor = (id: string, base?: string): SettingsData["agents"][number] => ({ + id, + base, + name: id, + scope: "project", + uiSelectable: true, + subagentRunnable: true, + }); + config.agents = [ + { ...descriptor("custom", "base"), aiDefaults: { thinkingLevel: "high" } }, + { ...descriptor("base", "exec"), aiDefaults: { model: "openai:gpt-5.6" } }, + descriptor("exec"), + ]; + config.config.agentAiDefaults = { + custom: { subagent: { modelString: "wrong:model", thinkingLevel: "max" } }, + base: { reasoningMode: "pro" }, + exec: { modelString: "wrong:exec", thinkingLevel: "low" }, + }; + expect(resolveSettings({}, config, "custom")).toMatchObject({ + model: "openai:gpt-5.6", + thinkingLevel: "high", + reasoningMode: "pro", + }); + config.config.agentAiDefaults.custom.modelString = "local:configured"; + expect(resolveSettings({}, config, "custom").model).toBe("local:configured"); + config.agents[0].aiDefaults = undefined; + expect(resolveSettings({}, config, "custom")).toMatchObject({ + model: "local:configured", + thinkingLevel: "low", + reasoningMode: "pro", + }); + config.agents[0].aiDefaults = { thinkingLevel: "high" }; + expect( + resolveSettings( + { + aiSettingsByAgent: { + custom: { + model: "local:workspace", + thinkingLevel: "low", + }, + }, + }, + config, + "custom" + ) + ).toMatchObject({ + model: "local:workspace", + thinkingLevel: "low", + reasoningMode: "standard", + }); + // The visited-set traversal must terminate while retaining valid ancestor fields. + config.agents[1].base = "custom"; + delete config.config.agentAiDefaults.custom.modelString; + expect(resolveSettings({}, config, "custom").model).toBe("openai:gpt-5.6"); + config.agents[0].base = "custom"; + expect(resolveSettings({}, config, "custom").thinkingLevel).toBe("high"); + config.agents[0].base = "missing"; + config.config.agentAiDefaults.missing = { modelString: "local:missing" }; + expect(resolveSettings({}, config, "custom").model).toBe("local:missing"); + }); + test("invalid persisted model falls through while valid fields retain precedence", () => { + const config = data(); + config.config.agentAiDefaults.exec = { modelString: "local:default", thinkingLevel: "low" }; + expect( + resolveSettings( + { aiSettingsByAgent: { exec: { model: "", thinkingLevel: "high" } } }, + config, + "exec" + ) + ).toMatchObject({ model: "local:default", thinkingLevel: "high" }); + }); + test("synced privacy and provider options survive explicit model/agent preferences", () => { + const config = data(); + const providerOptions = { + anthropic: { + disableBetaFeatures: true, + cacheTtl: "1h" as const, + use1MContextModels: ["anthropic:claude-sonnet-4-20250514"], + }, + google: { cache: false, custom: { enabled: true } }, + }; + config.config.userPreferences = { ai: { providerOptions } }; + const selected = resolveSettings({}, config, "exec"); + expect(selected.providerOptions).toEqual(providerOptions); + const changed = resolveSettings({}, config, "plan", { + ...selected, + model: "google:gemini-2.5-pro", + agentId: "plan", + thinkingLevel: "low", + }); + expect(changed).toMatchObject({ + model: "google:gemini-2.5-pro", + thinkingLevel: "low", + providerOptions, + }); + // Reconnected server preferences, not a stale local selection, own privacy settings. + config.config.userPreferences.ai!.providerOptions = { + anthropic: { disableBetaFeatures: false }, + }; + expect(resolveSettings({}, config, "plan", changed).providerOptions).toEqual({ + anthropic: { disableBetaFeatures: false }, + }); + }); + test.each(["coder", "openrouter"])("OpenAI auth gates follow the actual %s route", (gateway) => { + const config = data(); + config.providers.openai = { + isConfigured: true, + isEnabled: true, + apiKeySet: false, + codexOauthSet: true, + models: ["gpt-4o", "gpt-5.3-codex-spark"], + }; + config.providers[gateway] = { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: ["openai/gpt-4o", "openai/gpt-5.3-codex-spark"], + discoveredModels: ["openai/gpt-4o", "openai/gpt-5.3-codex-spark"], + }; + config.config.routePriority = [gateway, "direct"]; + expect(modelChoices(config, "")).toContain("openai:gpt-4o"); + config.providers.openai.apiKeySet = true; + config.providers.openai.codexOauthSet = false; + expect(modelChoices(config, "")).toContain("openai:gpt-5.3-codex-spark"); + config.config.routeOverrides = { "openai:gpt-5.3-codex-spark": "direct" }; + expect(modelChoices(config, "")).not.toContain("openai:gpt-5.3-codex-spark"); + config.config.routeOverrides = {}; + config.providers[gateway].isEnabled = false; + expect(modelChoices(config, "")).not.toContain("openai:gpt-5.3-codex-spark"); + }); }); diff --git a/packages/mobile/src/settings.ts b/packages/mobile/src/settings.ts index 27492a2b5f7..73f30f9587f 100644 --- a/packages/mobile/src/settings.ts +++ b/packages/mobile/src/settings.ts @@ -1,13 +1,12 @@ -import { - DEFAULT_MODEL, - KNOWN_MODELS, - MODEL_ABBREVIATIONS, -} from "../../../src/common/constants/knownModels"; +import { KNOWN_MODELS, MODEL_ABBREVIATIONS } from "../../../src/common/constants/knownModels"; import { isCodexOauthAllowedModel, isCodexOauthRequiredModel, } from "../../../src/common/constants/codexOAuth"; -import { isModelAvailable } from "../../../src/common/routing"; +import { isModelAvailable, resolveRoute } from "../../../src/common/routing"; +import { collectDeclaredAncestorLayers } from "../../../src/common/utils/ai/agentAncestorLayers"; +import { resolveAgentAiSettings } from "../../../src/common/utils/ai/resolveAgentAiSettings"; +import { targetWorkspaceBucketToLayer } from "../../../src/common/types/agentAiSettings"; import { normalizeToCanonical } from "../../../src/common/utils/ai/models"; import { formatModelDisplayName } from "../../../src/common/utils/ai/modelDisplay"; import { isProviderModelAccessibleFromAuthoritativeCatalog } from "../../../src/common/utils/providers/gatewayModelCatalog"; @@ -19,40 +18,59 @@ import type { ThinkingLevel } from "../../../src/common/types/thinking"; export type SettingsData = { config: Pick< Awaited>, - "agentAiDefaults" | "defaultModel" | "hiddenModels" | "routePriority" | "routeOverrides" + | "agentAiDefaults" + | "defaultModel" + | "hiddenModels" + | "routePriority" + | "routeOverrides" + | "userPreferences" >; providers: Awaited>; agents: Awaited>; }; export type ChatSettings = Pick< SendMessageOptions, - "model" | "agentId" | "thinkingLevel" | "reasoningMode" + "model" | "agentId" | "thinkingLevel" | "reasoningMode" | "providerOptions" >; export const thinkingLevels: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh", "max"]; export function resolveSettings( workspace: Pick, data: SettingsData, - agentId: string + agentId: string, + selection?: ChatSettings | null ): ChatSettings { const workspaceDefaults = workspace.aiSettingsByAgent?.[agentId] ?? (workspace.agentId === agentId ? workspace.aiSettings : undefined); - const globalDefaults = data.config.agentAiDefaults[agentId]; - const agentDefaults = data.agents.find((agent) => agent.id === agentId)?.aiDefaults; + const descriptors = new Map( + data.agents.map((agent) => [ + agent.id, + { + base: agent.base, + definitionAiDefaults: agent.aiDefaults, + }, + ]) + ); + // Reuse desktop/server field-wise inheritance, including Off and standard defaults. + const resolved = resolveAgentAiSettings({ + targetAgentId: agentId, + profile: "interactive", + explicit: selection ?? undefined, + targetWorkspaceSettings: workspaceDefaults + ? targetWorkspaceBucketToLayer(workspaceDefaults) + : undefined, + agentAiDefaults: data.config.agentAiDefaults, + targetDefinitionAiDefaults: descriptors.get(agentId)?.definitionAiDefaults, + ancestors: collectDeclaredAncestorLayers(agentId, descriptors), + defaultModel: data.config.defaultModel, + providersConfig: data.providers, + }); return { + ...resolved.selected, agentId, - model: - workspaceDefaults?.model ?? - globalDefaults?.modelString ?? - agentDefaults?.model ?? - data.config.defaultModel ?? - DEFAULT_MODEL, - thinkingLevel: - workspaceDefaults?.thinkingLevel ?? - globalDefaults?.thinkingLevel ?? - agentDefaults?.thinkingLevel, - reasoningMode: workspaceDefaults?.reasoningMode ?? globalDefaults?.reasoningMode, + // Server-synced preferences own privacy/cache settings, even after a local model switch. + providerOptions: data.config.userPreferences?.ai?.providerOptions, }; } @@ -97,7 +115,17 @@ export function modelChoices(data: SettingsData, currentModel: string): string[] ) ) return false; - if (!model.startsWith("openai:")) return true; + // Gate only the actual direct route; gateways supply their own credentials. + if ( + resolveRoute( + model, + data.config.routePriority ?? ["direct"], + data.config.routeOverrides ?? {}, + isConfigured, + isAccessible + ).routeProvider !== "openai" + ) + return true; const openai = data.providers.openai; if (openai?.apiKeySet && openai.codexOauthSet) return true; if (!openai?.apiKeySet && openai?.codexOauthSet) From 0792887858df8a6670742a81ed3ed4419f374e01 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 13:27:45 +0000 Subject: [PATCH 21/84] =?UTF-8?q?=F0=9F=A4=96=20fix:=20recover=20mobile=20?= =?UTF-8?q?sessions=20and=20replace=20sidebar=20detail=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep sessions usable when credential removal fails, release hidden conversation subscriptions on sidebar switches, and resume recovered questions only after durable answers. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$7.16`_ --- packages/mobile/App.tsx | 38 +- .../mobile/src/screens/ConversationScreen.tsx | 114 ++++- .../mobile/src/screens/formTestPlatform.ts | 10 + .../mobile/src/screens/session.behavior.tsx | 470 ++++++++++++++++++ packages/mobile/src/screens/session.test.ts | 30 ++ .../src/screens/sessionTestPlatform.tsx | 52 ++ 6 files changed, 702 insertions(+), 12 deletions(-) create mode 100644 packages/mobile/src/screens/session.behavior.tsx create mode 100644 packages/mobile/src/screens/session.test.ts create mode 100644 packages/mobile/src/screens/sessionTestPlatform.tsx diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx index b22234de701..2ded8d8471c 100644 --- a/packages/mobile/App.tsx +++ b/packages/mobile/App.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState } from "react"; +import { createContext, useContext, useRef, useState } from "react"; import type { ReactNode, SetStateAction } from "react"; import { StatusBar, Text, useWindowDimensions, View } from "react-native"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; @@ -66,7 +66,7 @@ export default function App() { ); } -function ConnectedApp(props: { connection: Connection; onDisconnect: () => void }) { +export function ConnectedApp(props: { connection: Connection; onDisconnect: () => void }) { const session = useConnection(props.connection); const data = useProjects(session.connection.client, session.signal); // Draft text and unsent model choices survive native back/pop and reconnection. @@ -77,17 +77,23 @@ function ConnectedApp(props: { connection: Connection; onDisconnect: () => void >(null); const [disconnectError, setDisconnectError] = useState(null); const [disconnecting, setDisconnecting] = useState(false); + const disconnectPending = useRef(false); async function disconnect() { - session.cancel(); + if (disconnectPending.current) return; + disconnectPending.current = true; setDisconnecting(true); setDisconnectError(null); try { + // Secure storage can fail. Keep the live session usable until forgetting succeeds. await clearCredentials(); - props.onDisconnect(); } catch { setDisconnectError("Could not clear saved credentials. Try disconnecting again."); + disconnectPending.current = false; setDisconnecting(false); + return; } + session.cancel(); + props.onDisconnect(); } const value: SessionContext = { session, @@ -199,9 +205,29 @@ function WorkspacesRoute(props: NativeStackScreenProps["navigation"], "navigate">; + navigation: Pick< + NativeStackScreenProps["navigation"], + "navigate" | "getState" | "reset" + >; }) { const { width } = useWindowDimensions(); + function selectWorkspace(workspaceId: string) { + const state = props.navigation.getState(); + const active = state.routes[state.index]; + if (active.name === "Conversation" && active.params?.workspaceId === workspaceId) return; + const conversation = state.routes.find( + (route) => route.name === "Conversation" && route.params?.workspaceId === workspaceId + ); + // A sidebar selection replaces the detail, not the back stack. Keeping hidden + // conversations mounted would retain their live subscriptions indefinitely. + props.navigation.reset({ + index: 1, + routes: [ + { name: "Workspaces", key: state.routes[0].key }, + { name: "Conversation", key: conversation?.key, params: { workspaceId } }, + ], + }); + } return ( {width >= WIDE_LAYOUT_MIN_WIDTH && ( @@ -209,7 +235,7 @@ function ScreenLayout(props: { props.navigation.navigate("Conversation", { workspaceId })} + onSelect={selectWorkspace} onSettings={() => props.navigation.navigate("Settings")} />
diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 0d0d70fd332..d5b225aa3aa 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -63,6 +63,7 @@ export function ConversationScreen(props: { const [inputHeight, setInputHeight] = useState(44); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); + const [resumeMessageId, setResumeMessageId] = useState(null); const [showSettings, setShowSettings] = useState<"model" | "agent" | null>(null); const [atBottom, setAtBottom] = useState(true); const [composerHeight, setComposerHeight] = useState(100); @@ -78,6 +79,12 @@ export function ConversationScreen(props: { setBusy(false); return () => abort.abort(); }, [props.signal]); + const latestTranscript = useRef(transcript); + // Answer RPCs can outlive stream updates from another client. Consult the latest + // committed transcript before starting recovery, not the pre-answer render. + useEffect(() => { + latestTranscript.current = transcript; + }, [transcript]); const agentId = props.workspace.agentId ?? "exec"; const options = settings ? resolveSettings( @@ -93,6 +100,17 @@ export function ConversationScreen(props: { const running = ready && transcript.streaming; const expanded = inputFocused || draft.length > 0 || running || showSettings !== null; + const lastMessage = transcript.messages.at(-1); + // Only the active stream or the latest persisted partial can still need input. + // Historical unanswered tools may have been abandoned by a later user turn. + const answerMessage = running + ? transcript.messages.find((message) => message.id === transcript.streamingMessageId) + : lastMessage?.role === "assistant" && lastMessage.metadata?.partial + ? lastMessage + : undefined; + const canResume = + ready && !running && resumeMessageId === lastMessage?.id && lastMessage?.metadata?.partial; + async function send() { if (!ready || !options?.model || !draft.trim() || pending.current || running) return; pending.current = true; @@ -153,13 +171,90 @@ export function ConversationScreen(props: { } } + async function resumeAnsweredQuestion(messageId: string, signal: AbortSignal) { + const current = latestTranscript.current; + const latest = current.messages.at(-1); + if ( + signal.aborted || + current.streaming || + latest?.id !== messageId || + !latest.metadata?.partial + ) + return; + if (!options?.model) return; + // The answer is already durable and its form may disappear on tool-call-end. + // Keep resume failures outside that form, and retry only resume, never the answer. + setResumeMessageId(messageId); + try { + const result = await props.client.workspace.resumeStream( + { workspaceId: props.workspace.id, options }, + { signal } + ); + if (signal.aborted) return; + if (!result.success) + throw new Error( + typeof result.error === "string" ? result.error : JSON.stringify(result.error) + ); + setResumeMessageId(null); + } catch (cause) { + if (!signal.aborted) + setActionError( + `Answers saved, but the agent could not resume: ${cause instanceof Error ? cause.message : "Unknown error"}` + ); + } + } + + async function retryResume() { + if (!canResume || !resumeMessageId || pending.current) return; + pending.current = true; + setBusy(true); + setActionError(null); + const signal = controller.current.signal; + try { + await resumeAnsweredQuestion(resumeMessageId, signal); + } finally { + if (controller.current.signal === signal) { + pending.current = false; + if (!signal.aborted) setBusy(false); + } + } + } + async function answer(toolCallId: string, answers: Record) { if (!ready) throw new Error("Reconnect before answering."); - const result = await props.client.workspace.answerAskUserQuestion( - { workspaceId: props.workspace.id, toolCallId, answers }, - { signal: controller.current.signal } - ); - if (!result.success) throw new Error(result.error); + if (pending.current) throw new Error("Another action is in progress."); + if ( + !answerMessage || + resumeMessageId === answerMessage.id || + !answerMessage.parts.some( + (part) => + part.type === "dynamic-tool" && + part.toolName === "ask_user_question" && + part.toolCallId === toolCallId && + part.state === "input-available" + ) + ) + throw new Error("This question is no longer pending."); + if (!running && !options?.model) throw new Error("Choose a model before resuming."); + pending.current = true; + setBusy(true); + setActionError(null); + const signal = controller.current.signal; + try { + const result = await props.client.workspace.answerAskUserQuestion( + { workspaceId: props.workspace.id, toolCallId, answers }, + { signal } + ); + if (signal.aborted) + throw new Error("Connection changed. Reload history before answering again."); + if (!result.success) throw new Error(result.error); + if (!running) await resumeAnsweredQuestion(answerMessage.id, signal); + } finally { + if (controller.current.signal === signal) { + pending.current = false; + if (!signal.aborted) setBusy(false); + } + } } return ( @@ -231,7 +326,9 @@ export function ConversationScreen(props: { )} @@ -251,6 +348,11 @@ export function ConversationScreen(props: { {error && {error}} {transcript.error && {transcript.error}} + {canResume && ( + + )} {running && ( Agent is working… )} diff --git a/packages/mobile/src/screens/formTestPlatform.ts b/packages/mobile/src/screens/formTestPlatform.ts index ab189dfcfde..2aa6963a3b7 100644 --- a/packages/mobile/src/screens/formTestPlatform.ts +++ b/packages/mobile/src/screens/formTestPlatform.ts @@ -12,6 +12,16 @@ mock.module("react-native-svg", () => ({ default: NativeWeb.View, Circle: icon } mock.module("lucide-react-native", () => Object.fromEntries( [ + "ArrowDown", + "ArrowUp", + "ArrowRight", + "Eye", + "EyeOff", + "GitCompareArrows", + "Square", + "CheckCircle2", + "FileCode", + "RefreshCw", "AlertCircle", "ArrowDown", "ArrowUp", diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx new file mode 100644 index 00000000000..1897f4ef846 --- /dev/null +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -0,0 +1,470 @@ +import { secureStore, stackState } from "./sessionTestPlatform"; +import { afterEach, expect, test } from "bun:test"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { createORPCClient } from "@orpc/client"; +import { ConnectedApp } from "../../App"; +import type { Connection } from "./ConnectScreen"; +import type { MobileClient } from "../api"; +import type { WorkspaceChatMessage } from "../transcript"; +import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; + +const model = "anthropic:claude-sonnet-4-5"; +const workspaces: FrontendWorkspaceMetadata[] = ["alpha", "beta"].map((id) => ({ + id, + name: id, + projectName: "project", + projectPath: "/project", + namedWorkspacePath: `/project/${id}`, + runtimeConfig: { type: "local" }, +})); +afterEach(() => { + cleanup(); + secureStore.clear = async () => {}; +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (cause: Error) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function fixture(messages: WorkspaceChatMessage[] = [], wide = false) { + Object.defineProperty(document.documentElement, "clientWidth", { + configurable: true, + value: wide ? 1200 : 375, + }); + act(() => window.dispatchEvent(new Event("resize"))); + const chats: Array<{ + workspaceId: string; + signal: AbortSignal; + events: ReadableStreamDefaultController; + end: () => void; + }> = []; + const calls: Array<{ path: string; input: unknown; signal?: AbortSignal }> = []; + let closed = 0; + let reconnected = 0; + let disconnected = 0; + let answer = async (): Promise => ({ success: true }); + let resume = async (): Promise => ({ success: true, data: { started: true } }); + function events( + signal?: AbortSignal, + initial: T[] = [], + onStart?: (controller: ReadableStreamDefaultController, end: () => void) => void + ) { + return new ReadableStream({ + start(controller) { + let ended = false; + const end = () => { + if (ended) return; + ended = true; + controller.close(); + }; + signal?.addEventListener("abort", end, { once: true }); + initial.forEach((event) => controller.enqueue(event)); + onStart?.(controller, end); + }, + }).values(); + } + const client = createORPCClient({ + call: async (path, input, options) => { + const name = path.join("."); + calls.push({ path: name, input, signal: options.signal }); + switch (name) { + case "workspace.onMetadata": + return events(options.signal); + case "workspace.list": + return workspaces; + case "projects.list": + return []; + case "config.getConfig": + return { agentAiDefaults: {}, defaultModel: model }; + case "providers.getConfig": + return {}; + case "agents.list": + return [ + { id: "exec", name: "Exec", uiSelectable: true }, + { id: "plan", name: "Plan", uiSelectable: true }, + ]; + case "workspace.onChat": { + if ( + !options.signal || + !input || + typeof input !== "object" || + !("workspaceId" in input) || + typeof input.workspaceId !== "string" + ) + throw new Error("Missing subscription identity"); + const workspaceId = input.workspaceId; + const signal = options.signal; + return events( + signal, + [...messages, { type: "caught-up" }], + (controller, end) => chats.push({ workspaceId, signal, events: controller, end }) + ); + } + case "workspace.answerAskUserQuestion": + return answer(); + case "workspace.resumeStream": + return resume(); + case "workspace.sendMessage": + return { success: true }; + case "workspace.executeBash": + return { success: true, data: { success: true, output: "" } }; + default: + throw new Error(`Unexpected call: ${name}`); + } + }, + }); + const connection: Connection = { + client, + endpoint: "https://example.test", + close() { + closed++; + }, + async reconnect() { + reconnected++; + return connection; + }, + }; + const view = render( + { + disconnected++; + view.unmount(); + }} + /> + ); + return { + ...view, + calls, + chats, + get closed() { + return closed; + }, + get reconnected() { + return reconnected; + }, + get disconnected() { + return disconnected; + }, + setAnswer(value: typeof answer) { + answer = value; + }, + setResume(value: typeof resume) { + resume = value; + }, + async select(id: string) { + fireEvent.click(await view.findByRole("button", { name: id })); + await waitFor(() => + expect( + view.getByRole("button", { name: "Choose mode" }).getAttribute("aria-disabled") + ).not.toBe("true") + ); + }, + async emit(event: WorkspaceChatMessage) { + await act(async () => chats.at(-1)!.events.enqueue(event)); + }, + }; +} + +test("failed credential clearing leaves the session usable and reconnectable before retrying disconnect", async () => { + const view = fixture(); + await view.select("alpha"); + fireEvent.click(view.getByRole("button", { name: "Connection settings" })); + fireEvent.click(view.getByRole("button", { name: "Disconnect" })); + const clear = deferred(); + secureStore.clear = () => clear.promise; + fireEvent.click(view.getByRole("button", { name: "Disconnect & forget credentials" })); + expect(view.closed).toBe(0); + expect(view.chats[0].signal.aborted).toBe(false); + await act(async () => clear.reject(new Error("keychain locked"))); + expect(view.disconnected).toBe(0); + expect(view.getByRole("alert")).toBeDefined(); + fireEvent.click(view.getByRole("button", { name: "Keep connection" })); + fireEvent.click(view.getByRole("button", { name: "Back" })); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Still usable" } }); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + expect(view.calls.filter((call) => call.path === "workspace.sendMessage")).toHaveLength(1); + await act(async () => view.chats[0].end()); + await act(async () => fireEvent.click(await view.findByRole("button", { name: "Retry" }))); + await waitFor(() => expect(view.reconnected).toBe(1)); + await waitFor(() => expect(view.chats).toHaveLength(2)); + secureStore.clear = async () => {}; + fireEvent.click(view.getByRole("button", { name: "Connection settings" })); + fireEvent.click(view.getByRole("button", { name: "Disconnect" })); + await act(async () => + fireEvent.click(view.getByRole("button", { name: "Disconnect & forget credentials" })) + ); + expect(view.disconnected).toBe(1); + expect(view.chats[1].signal.aborted).toBe(true); +}); + +test("wide selections replace detail subscriptions without losing the workspace anchor, drafts or choices", async () => { + const view = fixture([], true); + await view.select("alpha"); + const anchor = stackState.routes[0].key; + const active = stackState.routes[1].key; + fireEvent.change(view.getByLabelText("Message"), { target: { value: "alpha draft" } }); + fireEvent.click(view.getByRole("button", { name: "Choose mode" })); + fireEvent.click(view.getByRole("radio", { name: "Plan" })); + await view.select("alpha"); + expect(stackState.routes[1].key).toBe(active); + expect(view.chats).toHaveLength(1); + await view.select("beta"); + expect(view.chats[0].signal.aborted).toBe(true); + expect(view.chats.filter((chat) => !chat.signal.aborted).map((chat) => chat.workspaceId)).toEqual( + ["beta"] + ); + expect(stackState.routes).toHaveLength(2); + expect(stackState.routes[0].key).toBe(anchor); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "beta draft" } }); + await view.select("alpha"); + expect(view.getByLabelText("Message")).toHaveProperty("value", "alpha draft"); + expect(view.getByRole("button", { name: "Choose mode" }).textContent).toContain("Plan"); + fireEvent.click(view.getByRole("button", { name: "Back to workspaces" })); + expect(stackState.routes.map((route) => route.name)).toEqual(["Workspaces"]); + expect(view.chats.every((chat) => chat.signal.aborted)).toBe(true); + await view.select("beta"); + expect(view.getByLabelText("Message")).toHaveProperty("value", "beta draft"); +}); + +for (const detail of ["Settings", "Changes"] as const) { + test(`wide ${detail} selection prunes old details and reuses only the selected conversation`, async () => { + const view = fixture([], true); + await view.select("alpha"); + const alphaKey = stackState.routes[1].key; + const open = () => + fireEvent.click( + view.getByRole("button", { + name: detail === "Settings" ? "Connection settings" : "View changes", + }) + ); + open(); + expect(stackState.routes.map((route) => route.name)).toEqual([ + "Workspaces", + "Conversation", + detail, + ]); + await view.select("alpha"); + expect(stackState.routes).toHaveLength(2); + expect(stackState.routes[1].key).toBe(alphaKey); + expect(view.chats).toHaveLength(1); + open(); + await view.select("beta"); + expect(stackState.routes).toHaveLength(2); + expect(view.chats[0].signal.aborted).toBe(true); + expect( + view.calls + .filter((call) => call.path === "workspace.executeBash") + .every((call) => call.signal?.aborted) + ).toBe(true); + fireEvent.click(view.getByRole("button", { name: "Back to workspaces" })); + expect(stackState.routes.map((route) => route.name)).toEqual(["Workspaces"]); + }); +} + +test("settings opened from the wide workspace root retains the back anchor when selecting a workspace", async () => { + const view = fixture([], true); + fireEvent.click(await view.findByRole("button", { name: "Settings" })); + await view.select("beta"); + expect(stackState.routes.map((route) => route.name)).toEqual(["Workspaces", "Conversation"]); + fireEvent.click(view.getByRole("button", { name: "Connection settings" })); + fireEvent.click(view.getByRole("button", { name: "Back" })); + expect(view.chats).toHaveLength(1); + expect(view.chats[0].signal.aborted).toBe(false); +}); + +function question( + id = "question", + sequence = 1, + partial = true +): Extract { + return { + type: "message", + id, + role: "assistant", + metadata: { historySequence: sequence, partial }, + parts: [ + { + type: "dynamic-tool", + toolName: "ask_user_question", + toolCallId: id, + state: "input-available", + input: { questions: [{ question: `Answer ${id}?` }] }, + }, + ], + }; +} +function answered(id = "question"): WorkspaceChatMessage { + return { + type: "tool-call-end", + workspaceId: "alpha", + messageId: id, + toolCallId: id, + toolName: "ask_user_question", + result: { summary: "answered" }, + timestamp: 1, + }; +} +function callCount(view: ReturnType, name: string) { + return view.calls.filter((call) => call.path === `workspace.${name}`).length; +} +async function submitAnswer(view: ReturnType, id = "question") { + fireEvent.change(view.getByLabelText(`Answer ${id}?`), { target: { value: "main" } }); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send answers" }))); +} + +test("the latest recovered partial can be answered once and resumes only after saving with current options", async () => { + const view = fixture([question()]); + await view.select("alpha"); + const answer = deferred(); + view.setAnswer(() => answer.promise); + await submitAnswer(view); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(0); + await view.emit(answered()); + await act(async () => answer.resolve({ success: true })); + expect(callCount(view, "resumeStream")).toBe(1); + expect(view.calls.find((call) => call.path === "workspace.resumeStream")?.input).toMatchObject({ + workspaceId: "alpha", + options: { model, agentId: "exec" }, + }); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); +}); + +test("live questions do not resume, and older pending partials stay disabled while streaming", async () => { + const view = fixture([ + question("old"), + { + type: "stream-start", + workspaceId: "alpha", + messageId: "live", + historySequence: 2, + startTime: 1, + model, + }, + { + type: "tool-call-start", + workspaceId: "alpha", + messageId: "live", + toolCallId: "live", + toolName: "ask_user_question", + tokens: 1, + args: { questions: [{ question: "Answer live?" }] }, + timestamp: 1, + }, + ]); + await view.select("alpha"); + expect(view.getByLabelText("Answer old?").getAttribute("readonly")).not.toBeNull(); + fireEvent.change(view.getByLabelText("Answer live?"), { target: { value: "main" } }); + const buttons = view.getAllByRole("button", { name: "Send answers" }); + await act(async () => buttons.forEach((button) => fireEvent.click(button))); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(0); +}); + +for (const latest of [ + question("later", 2), + { + type: "message", + id: "user", + role: "user", + parts: [{ type: "text", text: "Move on" }], + metadata: { historySequence: 2 }, + }, +] satisfies WorkspaceChatMessage[]) { + test(`an old recovered question is not re-enabled by a later ${latest.role} message`, async () => { + const view = fixture([question("old"), latest]); + await view.select("alpha"); + expect(view.getByLabelText("Answer old?").getAttribute("readonly")).not.toBeNull(); + expect(callCount(view, "answerAskUserQuestion")).toBe(0); + }); +} + +test("a complete historical pending question is not recoverable", async () => { + const view = fixture([question("complete", 1, false)]); + await view.select("alpha"); + expect(view.getByLabelText("Answer complete?").getAttribute("readonly")).not.toBeNull(); +}); + +test("answer failure is retryable without resuming; resume failure survives tool completion and retries only resume", async () => { + const view = fixture([question()]); + await view.select("alpha"); + view.setAnswer(async () => ({ success: false, error: "storage unavailable" })); + await submitAnswer(view); + expect(view.getByRole("alert").textContent).toContain("storage unavailable"); + expect(callCount(view, "resumeStream")).toBe(0); + view.setAnswer(async () => { + view.chats.at(-1)!.events.enqueue(answered()); + return { success: true }; + }); + view.setResume(async () => ({ + success: false, + error: { type: "unknown", raw: "resume unavailable" }, + })); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send answers" }))); + expect(view.getByRole("alert").textContent).toContain("resume unavailable"); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); + const resumed = deferred(); + view.setResume(() => resumed.promise); + fireEvent.click(view.getByRole("button", { name: "Resume agent" })); + fireEvent.click(view.getByRole("button", { name: "Resume agent" })); + expect(callCount(view, "resumeStream")).toBe(2); + expect(callCount(view, "answerAskUserQuestion")).toBe(2); + await act(async () => resumed.resolve({ success: true, data: { started: true } })); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + expect(view.queryByRole("alert")).toBeNull(); +}); + +test("a competing stream or newer turn prevents recovery from resuming a stale answer", async () => { + for (const event of [ + { + type: "stream-start", + workspaceId: "alpha", + messageId: "other", + historySequence: 2, + startTime: 1, + model, + }, + { + type: "message", + id: "next", + role: "user", + parts: [{ type: "text", text: "Move on" }], + metadata: { historySequence: 2 }, + }, + ] satisfies WorkspaceChatMessage[]) { + const view = fixture([question()]); + await view.select("alpha"); + const pending = deferred(); + view.setAnswer(() => pending.promise); + await submitAnswer(view); + await view.emit(event); + await act(async () => pending.resolve({ success: true })); + expect(callCount(view, "resumeStream")).toBe(0); + view.unmount(); + } +}); + +test("reconnect cancels the old answer's resume continuation and reconciles pending recovery", async () => { + const view = fixture([question()], true); + await view.select("alpha"); + const oldAnswer = deferred(); + view.setAnswer(() => oldAnswer.promise); + await submitAnswer(view); + await act(async () => view.chats[0].end()); + await act(async () => fireEvent.click(await view.findByRole("button", { name: "Retry" }))); + await waitFor(() => expect(view.chats).toHaveLength(2)); + await act(async () => oldAnswer.resolve({ success: true })); + expect(callCount(view, "resumeStream")).toBe(0); + view.setAnswer(async () => ({ success: true })); + await submitAnswer(view); + expect(callCount(view, "answerAskUserQuestion")).toBe(2); + expect(callCount(view, "resumeStream")).toBe(1); +}); diff --git a/packages/mobile/src/screens/session.test.ts b/packages/mobile/src/screens/session.test.ts new file mode 100644 index 00000000000..d97022d443c --- /dev/null +++ b/packages/mobile/src/screens/session.test.ts @@ -0,0 +1,30 @@ +import { test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +// Isolate native host aliases from the ordinary hook/transport suites. +test("mobile session and recovery behavior", async () => { + const child = Bun.spawn( + [ + process.execPath, + "test", + "--preload", + "./src/screens/formTestDom.ts", + "--preload", + "./src/screens/formTestPlatform.ts", + "--preload", + "./src/screens/sessionTestPlatform.tsx", + "./src/screens/session.behavior.tsx", + ], + { cwd: fileURLToPath(new URL("../../", import.meta.url)), stdout: "pipe", stderr: "pipe" } + ); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) throw new Error(`Session tests failed (${code}):\n${stdout}\n${stderr}`); + } finally { + if (child.exitCode === null) child.kill(); + } +}, 30_000); diff --git a/packages/mobile/src/screens/sessionTestPlatform.tsx b/packages/mobile/src/screens/sessionTestPlatform.tsx new file mode 100644 index 00000000000..9df4539746f --- /dev/null +++ b/packages/mobile/src/screens/sessionTestPlatform.tsx @@ -0,0 +1,52 @@ +import "./formTestPlatform"; +import { mock } from "bun:test"; +import { + createNavigatorFactory, + StackRouter, + useNavigationBuilder, +} from "@react-navigation/native"; +import type { + ParamListBase, + StackActionHelpers, + StackNavigationState, + StackRouterOptions, +} from "@react-navigation/native"; +import type { + NativeStackNavigationEventMap, + NativeStackNavigationOptions, + NativeStackNavigatorProps, +} from "@react-navigation/native-stack"; +// @ts-expect-error React Native Web publishes JS only; production types use React Native. +import * as NativeWeb from "react-native-web"; + +export let stackState: StackNavigationState; +// Keep the real stack router and all retained screens mounted, substituting only +// the native view host. These tests must catch subscriptions hidden below the active route. +function TestStack(props: NativeStackNavigatorProps) { + const { state, descriptors, NavigationContent } = useNavigationBuilder< + StackNavigationState, + StackRouterOptions, + StackActionHelpers, + NativeStackNavigationOptions, + NativeStackNavigationEventMap + >(StackRouter, props); + stackState = state; + return ( + + {state.routes.map((route, index) => ( +
+ ))} + + ); +} +mock.module("@react-navigation/native-stack", () => ({ + createNativeStackNavigator: createNavigatorFactory(TestStack), +})); +mock.module("react-native-safe-area-context", () => ({ + SafeAreaView: NativeWeb.View, + SafeAreaProvider: NativeWeb.View, +})); +export const secureStore = { clear: async () => {} }; +mock.module("expo-secure-store", () => ({ deleteItemAsync: () => secureStore.clear() })); From d7bb5156d126ed9041d93d80843889bee0f57bd5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 13:38:10 +0000 Subject: [PATCH 22/84] =?UTF-8?q?=F0=9F=A4=96=20docs:=20refresh=20bundled?= =?UTF-8?q?=20mobile=20compatibility=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate the built-in documentation snapshot for the matching mobile/server revision guidance added with bulk project diffs. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$757.08`_ --- src/node/services/agentSkills/builtInSkillContent.generated.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 65d45b9a960..d70efe877af 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6992,6 +6992,8 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Enable [server access](/config/server-access), or start `xum server`. Use a trusted HTTPS endpoint accessible from the device and enter the server's bearer token separately. Include any reverse-proxy path prefix in the endpoint. A Coder login page or another upstream authentication layer may require additional network access; the Xum token does not authenticate to that outer layer.", "", + "During development, run the mobile client and server from the same branch/revision. Their shared API contract evolves together; for example, the multi-repository changes view requires the server's bulk project-diff endpoint.", + "", "The token grants access to the server, including its code-execution capabilities. Treat it like a password. Native builds save connection details in device secure storage. The web preview keeps them in memory only; refreshing requires entering them again. Disconnect clears the saved native connection.", "", "Public endpoints require HTTPS. Literal private LAN and loopback HTTP addresses are accepted for development, with a plaintext-token warning. Mobile platform transport policies may still restrict cleartext networking; prefer HTTPS on devices. A phone's `localhost` refers to the phone, not your development computer.", From 5c1d1e108c92964e456a0a6126e0d444a0478131 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 13:45:38 +0000 Subject: [PATCH 23/84] =?UTF-8?q?=F0=9F=A4=96=20tests:=20deduplicate=20mer?= =?UTF-8?q?ged=20mobile=20icon=20aliases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicate test-only aliases introduced while combining settings and session regressions. Mobile checks, root static checks, and workflow lint/security checks pass. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$771.46`_ --- packages/mobile/src/screens/formTestPlatform.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/mobile/src/screens/formTestPlatform.ts b/packages/mobile/src/screens/formTestPlatform.ts index 2aa6963a3b7..5aaaba07f9d 100644 --- a/packages/mobile/src/screens/formTestPlatform.ts +++ b/packages/mobile/src/screens/formTestPlatform.ts @@ -23,10 +23,6 @@ mock.module("lucide-react-native", () => "FileCode", "RefreshCw", "AlertCircle", - "ArrowDown", - "ArrowUp", - "GitCompareArrows", - "Square", "ChevronLeft", "Info", "TriangleAlert", @@ -50,9 +46,6 @@ mock.module("lucide-react-native", () => "ShieldCheck", "Brain", "File", - "FileCode", - "CheckCircle2", - "RefreshCw", "Pause", "Wrench", ].map((name) => [name, icon]) From 5d8891563d7b61e66a055be2678edd004474d145 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 15:32:53 +0000 Subject: [PATCH 24/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20retain=20re?= =?UTF-8?q?covery=20retries=20and=20app=20goal=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve the saved-answer recovery action after an admission no-op and retain desktop app goal capability on mobile sends and resumes. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$863.43`_ --- .../mobile/src/screens/ConversationScreen.tsx | 3 ++- .../mobile/src/screens/forms.behavior.tsx | 1 + .../mobile/src/screens/session.behavior.tsx | 21 ++++++++++++++++++- packages/mobile/src/settings.test.ts | 1 + packages/mobile/src/settings.ts | 4 +++- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index d5b225aa3aa..4bfab3f4a11 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -195,7 +195,8 @@ export function ConversationScreen(props: { throw new Error( typeof result.error === "string" ? result.error : JSON.stringify(result.error) ); - setResumeMessageId(null); + if (result.data.started) setResumeMessageId(null); + else setActionError("Answers saved. The agent is busy; try resuming again."); } catch (cause) { if (!signal.aborted) setActionError( diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 268743ac1c0..b7897672ee9 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -474,6 +474,7 @@ test.each([ model, agentId: scenario.selected ? "plan" : "exec", thinkingLevel: "off", + allowAgentSetGoal: true, providerOptions, }); expect(view.getByText(requests[0].options.thinkingLevel!.toUpperCase())).toBeDefined(); diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index 1897f4ef846..ff59e7b3b38 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -333,7 +333,7 @@ test("the latest recovered partial can be answered once and resumes only after s expect(callCount(view, "resumeStream")).toBe(1); expect(view.calls.find((call) => call.path === "workspace.resumeStream")?.input).toMatchObject({ workspaceId: "alpha", - options: { model, agentId: "exec" }, + options: { model, agentId: "exec", allowAgentSetGoal: true }, }); expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); }); @@ -393,6 +393,25 @@ test("a complete historical pending question is not recoverable", async () => { expect(view.getByLabelText("Answer complete?").getAttribute("readonly")).not.toBeNull(); }); +test("a successful no-op resume keeps the recovery action without resubmitting the answer", async () => { + const view = fixture([question()]); + await view.select("alpha"); + view.setAnswer(async () => { + view.chats.at(-1)!.events.enqueue(answered()); + return { success: true }; + }); + view.setResume(async () => ({ success: true, data: { started: false } })); + await submitAnswer(view); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(1); + const retry = view.getByRole("button", { name: "Resume agent" }); + view.setResume(async () => ({ success: true, data: { started: true } })); + await act(async () => fireEvent.click(retry)); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(2); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); +}); + test("answer failure is retryable without resuming; resume failure survives tool completion and retries only resume", async () => { const view = fixture([question()]); await view.select("alpha"); diff --git a/packages/mobile/src/settings.test.ts b/packages/mobile/src/settings.test.ts index 96a2052848d..6481121e7e3 100644 --- a/packages/mobile/src/settings.test.ts +++ b/packages/mobile/src/settings.test.ts @@ -118,6 +118,7 @@ describe("mobile model settings", () => { ) ).toEqual({ agentId: "exec", + allowAgentSetGoal: true, model: "workspace:exec", thinkingLevel: "high", reasoningMode: "standard", diff --git a/packages/mobile/src/settings.ts b/packages/mobile/src/settings.ts index 73f30f9587f..60998b0ce18 100644 --- a/packages/mobile/src/settings.ts +++ b/packages/mobile/src/settings.ts @@ -30,7 +30,7 @@ export type SettingsData = { }; export type ChatSettings = Pick< SendMessageOptions, - "model" | "agentId" | "thinkingLevel" | "reasoningMode" | "providerOptions" + "model" | "agentId" | "thinkingLevel" | "reasoningMode" | "providerOptions" | "allowAgentSetGoal" >; export const thinkingLevels: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh", "max"]; @@ -69,6 +69,8 @@ export function resolveSettings( return { ...resolved.selected, agentId, + // App-initiated turns retain the same goal capability as desktop, including recovery. + allowAgentSetGoal: true, // Server-synced preferences own privacy/cache settings, even after a local model switch. providerOptions: data.config.userPreferences?.ai?.providerOptions, }; From d65367bcad2d6a8adebd492679dc53ce9eaa8d32 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 15:32:29 +0000 Subject: [PATCH 25/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20support=20s?= =?UTF-8?q?tructured=20questions=20and=20searchable=20subagents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render canonical question options and descriptions with single/multi-select, ordered answer serialization, and explicit Other text. Keep subagents out of the default workspace list while making them selectable through search with their parent label. Validation: five scoped behavior regressions, full form suite, mobile typecheck/lint/format, and iOS/web exports pass. The full mobile suite remains blocked by the separately-owned session fixtures that still use malformed question payloads and the old text-field selectors (80 pass, 1 skip, 1 failing session wrapper). --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- packages/mobile/src/components/Message.tsx | 144 +++++++++++++----- packages/mobile/src/screens/Navigator.tsx | 14 +- .../mobile/src/screens/forms.behavior.tsx | 129 +++++++++++++++- 3 files changed, 235 insertions(+), 52 deletions(-) diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index d916d2f3347..35ab1c2b798 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; -import { Brain, ChevronDown, ChevronRight, File, Pause, Wrench } from "lucide-react-native"; +import { Brain, Check, ChevronDown, ChevronRight, File, Pause, Wrench } from "lucide-react-native"; import type { MuxMessage, MuxToolPart } from "../../../../src/common/types/message"; +import type { AskUserQuestionQuestion } from "../../../../src/common/types/tools"; +import { AskUserQuestionToolArgsSchema } from "../../../../src/common/utils/tools/toolDefinitions"; import { Button, Field, Notice, Sheet } from "./Controls"; import { Markdown } from "./Markdown"; import { mergeAdjacentParts } from "../../../../src/common/utils/messages/mergeAdjacentParts"; @@ -165,7 +167,7 @@ function Tool(props: { const [inspecting, setInspecting] = useState(false); const questions = props.part.toolName === "ask_user_question" && props.part.state === "input-available" - ? questionTexts(props.part.input) + ? (AskUserQuestionToolArgsSchema.safeParse(props.part.input).data?.questions ?? []) : []; const name = props.part.toolName .replaceAll("_", " ") @@ -221,35 +223,39 @@ function Tool(props: { ); } -function questionTexts(input: unknown): string[] { - if ( - !input || - typeof input !== "object" || - !("questions" in input) || - !Array.isArray(input.questions) - ) - return []; - return input.questions.flatMap((question: unknown) => - question && - typeof question === "object" && - "question" in question && - typeof question.question === "string" - ? [question.question] - : [] - ); +interface QuestionDraft { + selected: Array; + otherText: string; } function QuestionForm(props: { - questions: string[]; + questions: AskUserQuestionQuestion[]; disabled: boolean; onSubmit: (answers: Record) => Promise; }) { - const [answers, setAnswers] = useState>({}); + const [drafts, setDrafts] = useState(() => new Map()); + // Match desktop answer serialization: selection order, comma-separated labels, + // and trimmed Other text. Null keeps the implicit choice distinct from tool labels. + const answers = Object.fromEntries( + props.questions.map((question) => { + const draft = drafts.get(question.question); + const complete = + draft && + draft.selected.length > 0 && + (!draft.selected.includes(null) || draft.otherText.trim().length > 0); + return [ + question.question, + complete ? draft.selected.map((label) => label ?? draft.otherText.trim()).join(", ") : "", + ]; + }) + ); const [busy, setBusy] = useState(false); const [submitted, setSubmitted] = useState(false); const [error, setError] = useState(null); + const disabled = props.disabled || busy || submitted; + const complete = props.questions.every((question) => Boolean(answers[question.question])); async function submit() { - if (busy || submitted) return; + if (disabled || !complete) return; setBusy(true); setError(null); try { @@ -264,27 +270,73 @@ function QuestionForm(props: { return ( Your input is needed - {props.questions.map((question) => ( - setAnswers({ ...answers, [question]: answer })} - placeholder="Your answer…" - multiline - editable={!props.disabled && !busy && !submitted} - /> - ))} + {props.questions.map((question) => { + const draft = drafts.get(question.question) ?? { selected: [], otherText: "" }; + return ( + + {question.header} + {question.question} + {[...question.options, { label: null, description: "Provide a custom answer." }].map( + (option) => { + const checked = draft.selected.includes(option.label); + return ( + + setDrafts((current) => + new Map(current).set(question.question, { + selected: checked + ? draft.selected.filter((label) => label !== option.label) + : question.multiSelect + ? [...draft.selected, option.label] + : [option.label], + otherText: + !question.multiSelect && option.label !== null ? "" : draft.otherText, + }) + ) + } + style={[styles.option, checked && { backgroundColor: colors.elevated }]} + > + + {option.label ?? "Other"} + {option.description} + + + {checked && } + + + ); + } + )} + {draft.selected.includes(null) && ( + + setDrafts((current) => + new Map(current).set(question.question, { ...draft, otherText }) + ) + } + placeholder="Your answer…" + multiline + editable={!disabled} + /> + )} + + ); + })} {error && {error}} - @@ -316,6 +368,16 @@ const styles = StyleSheet.create({ toolHint: { color: colors.text }, outputSurface: { backgroundColor: colors.panel, borderRadius: radii.control }, output: { ...typography.footnote, color: colors.text, fontFamily: mono, lineHeight: 21 }, + option: { + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + minHeight: 44, + padding: spacing.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radii.control, + }, question: { gap: spacing.lg, borderRadius: radii.card, diff --git a/packages/mobile/src/screens/Navigator.tsx b/packages/mobile/src/screens/Navigator.tsx index 64a0004ee24..6ab4c80e3bb 100644 --- a/packages/mobile/src/screens/Navigator.tsx +++ b/packages/mobile/src/screens/Navigator.tsx @@ -58,8 +58,12 @@ export function Navigator(props: { name: config.displayName ?? path.split(/[\\/]/).filter(Boolean).at(-1) ?? path, workspaces: [], }); - // Match desktop's root list before searching/counting; keep orphaned agents accessible. - for (const workspace of excludeSubAgentRows(props.workspaces)) { + const workspaceNames = new Map( + props.workspaces.map((workspace) => [workspace.id, workspace.title ?? workspace.name]) + ); + // Keep the default root list clean without making delegated chats unreachable. + const candidates = query.trim() ? props.workspaces : excludeSubAgentRows(props.workspaces); + for (const workspace of candidates) { const key = workspace.kind === "scratch" ? "scratch" : workspace.projectPath; const group = groups.get(key) ?? { name: workspace.kind === "scratch" ? "Scratch chats" : workspace.projectName, @@ -221,7 +225,11 @@ export function Navigator(props: { {workspace.title ?? workspace.name} - {workspace.kind === "scratch" ? "Scratch chat" : workspace.name} + {workspace.parentWorkspaceId + ? `Subagent of ${workspaceNames.get(workspace.parentWorkspaceId) ?? workspace.parentWorkspaceId}` + : workspace.kind === "scratch" + ? "Scratch chat" + : workspace.name}
diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index b7897672ee9..a5b97aa8825 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -1,7 +1,7 @@ import "./formTestPlatform"; import { afterEach, expect, test } from "bun:test"; import { createRef, useState } from "react"; -import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; import { createORPCClient } from "@orpc/client"; import { View } from "react-native"; import type { TextInput } from "react-native"; @@ -97,7 +97,8 @@ test("context meter exposes measured progress without inventing an unknown perce expect(view.getByRole("progressbar").getAttribute("aria-valuetext")).toBeTruthy(); }); -test("navigator counts and searches roots, not their agents, and preserves orphan access", () => { +test("navigator keeps roots by default but opens matching agents through search", () => { + const selected: string[] = []; const child: FrontendWorkspaceMetadata = { ...workspace, id: "child", @@ -111,7 +112,7 @@ test("navigator counts and searches roots, not their agents, and preserves orpha loading: false, error: null, onRetry: () => {}, - onSelect: () => {}, + onSelect: (value: FrontendWorkspaceMetadata) => selected.push(value.id), onCreate: () => {}, onSettings: () => {}, }; @@ -120,8 +121,12 @@ test("navigator counts and searches roots, not their agents, and preserves orpha expect(view.queryByRole("button", { name: child.title })).toBeNull(); const search = view.getByRole("textbox", { name: "Search workspaces" }); fireEvent.change(search, { target: { value: "Scout" } }); - expect(view.getByText("No matching workspaces")).toBeDefined(); - fireEvent.change(search, { target: { value: "" } }); + fireEvent.click(view.getByRole("button", { name: child.title })); + expect(selected).toEqual([child.id]); + expect(view.getByText("Subagent of feature")).toBeDefined(); + expect(view.queryByRole("button", { name: workspace.name })).toBeNull(); + fireEvent.change(search, { target: { value: " " } }); + expect(view.queryByRole("button", { name: child.title })).toBeNull(); // A live metadata update must not promote a running child into a peer row. view.rerender( @@ -746,7 +751,28 @@ test("question answers remain inline and require complete input before submissio toolCallId: "question", toolName: "ask_user_question", state: "input-available", - input: { questions: [{ question: "Which branch?" }, { question: "What should change?" }] }, + input: { + questions: [ + { + question: "Which branch?", + header: "Branch", + options: [ + { label: "main", description: "Stable branch" }, + { label: "next", description: "Upcoming release" }, + ], + multiSelect: false, + }, + { + question: "What should change?", + header: "Scope", + options: [ + { label: "API", description: "Change the interface" }, + { label: "UI", description: "Change the presentation" }, + ], + multiSelect: false, + }, + ], + }, }; const view = render( { @@ -774,6 +811,82 @@ test("question answers remain inline and require complete input before submissio ]); }); +test.each(["Which features?", "__proto__"])( + "multi-select question %s preserves selection order and custom text across a failed send", + async (question) => { + const answers: Array> = []; + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "features", + toolName: "ask_user_question", + state: "input-available", + input: { + questions: [ + { + question, + header: "Features", + options: [ + { label: "Search", description: "Find workspaces" }, + { label: "Tabs", description: "Switch conversations" }, + ], + multiSelect: true, + }, + ], + }, + }; + const onAnswer = async (_id: string, value: Record) => { + answers.push(value); + if (answers.length === 1) throw new Error("Connection lost"); + }; + const view = render( + + ); + const search = view.getByRole("checkbox", { name: "Search" }); + fireEvent.click(search); + expect(search.getAttribute("aria-checked")).toBe("false"); + view.rerender(); + fireEvent.click(view.getByRole("checkbox", { name: "Tabs" })); + fireEvent.click(search); + fireEvent.click(search); + expect(search.getAttribute("aria-checked")).toBe("false"); + fireEvent.click(view.getByRole("checkbox", { name: "Other" })); + fireEvent.change(view.getByLabelText(`Other: ${question}`), { target: { value: " " } }); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(answers).toHaveLength(0); + fireEvent.change(view.getByLabelText(`Other: ${question}`), { + target: { value: " Offline, too " }, + }); + fireEvent.click(search); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + }); + expect(answers).toEqual([{ [question]: "Tabs, Offline, too, Search" }]); + expect(view.getByRole("alert").textContent).toContain("Connection lost"); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + }); + expect(answers[1]).toEqual(answers[0]); + fireEvent.click(view.getByRole("checkbox", { name: "Tabs" })); + expect(view.getByRole("checkbox", { name: "Tabs" }).getAttribute("aria-checked")).toBe("true"); + fireEvent.click(view.getByRole("button", { name: "Answers sent" })); + expect(answers).toHaveLength(2); + } +); + +test("malformed question payloads stay inspectable without presenting an incomplete answer form", () => { + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "malformed", + toolName: "ask_user_question", + state: "input-available", + input: { questions: [{ question: "Missing choices?" }] }, + }; + const view = render( {}} />); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Ask user question: No result" })); + expect(view.getByText(/Missing choices/)).toBeDefined(); +}); + test("reasoning stays an inline disclosure and historical errors are not replaced by an empty-response hint", () => { const message: MuxMessage = { id: "reasoning", From 14679735190da8f78625f4dd1f75aa79e276a734 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 15:35:59 +0000 Subject: [PATCH 26/84] =?UTF-8?q?=F0=9F=A4=96=20tests:=20exercise=20recove?= =?UTF-8?q?ry=20with=20canonical=20question=20choices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the session recovery assertions on real tool payloads and selectable option controls after structured-question support. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$865.71`_ --- .../mobile/src/screens/session.behavior.tsx | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index ff59e7b3b38..e7708e1b1d0 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -1,6 +1,6 @@ import { secureStore, stackState } from "./sessionTestPlatform"; import { afterEach, expect, test } from "bun:test"; -import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; import { createORPCClient } from "@orpc/client"; import { ConnectedApp } from "../../App"; import type { Connection } from "./ConnectScreen"; @@ -279,6 +279,22 @@ test("settings opened from the wide workspace root retains the back anchor when expect(view.chats[0].signal.aborted).toBe(false); }); +function questionInput(id: string) { + return { + questions: [ + { + question: `Answer ${id}?`, + header: "Branch", + options: [ + { label: "main", description: "Stable branch" }, + { label: "next", description: "Upcoming release" }, + ], + multiSelect: false, + }, + ], + }; +} + function question( id = "question", sequence = 1, @@ -295,7 +311,7 @@ function question( toolName: "ask_user_question", toolCallId: id, state: "input-available", - input: { questions: [{ question: `Answer ${id}?` }] }, + input: questionInput(id), }, ], }; @@ -315,7 +331,11 @@ function callCount(view: ReturnType, name: string) { return view.calls.filter((call) => call.path === `workspace.${name}`).length; } async function submitAnswer(view: ReturnType, id = "question") { - fireEvent.change(view.getByLabelText(`Answer ${id}?`), { target: { value: "main" } }); + fireEvent.click( + within(view.getByRole("radiogroup", { name: `Answer ${id}?` })).getByRole("radio", { + name: "main", + }) + ); await act(async () => fireEvent.click(view.getByRole("button", { name: "Send answers" }))); } @@ -356,13 +376,21 @@ test("live questions do not resume, and older pending partials stay disabled whi toolCallId: "live", toolName: "ask_user_question", tokens: 1, - args: { questions: [{ question: "Answer live?" }] }, + args: questionInput("live"), timestamp: 1, }, ]); await view.select("alpha"); - expect(view.getByLabelText("Answer old?").getAttribute("readonly")).not.toBeNull(); - fireEvent.change(view.getByLabelText("Answer live?"), { target: { value: "main" } }); + expect( + within(view.getByRole("radiogroup", { name: "Answer old?" })) + .getByRole("radio", { name: "main" }) + .getAttribute("aria-disabled") + ).toBe("true"); + fireEvent.click( + within(view.getByRole("radiogroup", { name: "Answer live?" })).getByRole("radio", { + name: "main", + }) + ); const buttons = view.getAllByRole("button", { name: "Send answers" }); await act(async () => buttons.forEach((button) => fireEvent.click(button))); expect(callCount(view, "answerAskUserQuestion")).toBe(1); @@ -382,7 +410,11 @@ for (const latest of [ test(`an old recovered question is not re-enabled by a later ${latest.role} message`, async () => { const view = fixture([question("old"), latest]); await view.select("alpha"); - expect(view.getByLabelText("Answer old?").getAttribute("readonly")).not.toBeNull(); + expect( + within(view.getByRole("radiogroup", { name: "Answer old?" })) + .getByRole("radio", { name: "main" }) + .getAttribute("aria-disabled") + ).toBe("true"); expect(callCount(view, "answerAskUserQuestion")).toBe(0); }); } @@ -390,7 +422,11 @@ for (const latest of [ test("a complete historical pending question is not recoverable", async () => { const view = fixture([question("complete", 1, false)]); await view.select("alpha"); - expect(view.getByLabelText("Answer complete?").getAttribute("readonly")).not.toBeNull(); + expect( + within(view.getByRole("radiogroup", { name: "Answer complete?" })) + .getByRole("radio", { name: "main" }) + .getAttribute("aria-disabled") + ).toBe("true"); }); test("a successful no-op resume keeps the recovery action without resubmitting the answer", async () => { From 5ea095112491944030810577edcdc5e2d3f9ee76 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 15:34:35 +0000 Subject: [PATCH 27/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20enforce=20l?= =?UTF-8?q?ive=20server=20policy=20before=20conversation=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PRRT_kwDOPxxmWM6f8a4D with lifetime-scoped policy loading and updates, canonical resolved-route restrictions, and fail-closed send/answer/resume gating while retaining model selection and draft access. Validate with mobile-check (88 pass, one real-server test skipped), web export, and iOS Hermes export. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$0.00`_ --- packages/mobile/src/api.ts | 2 +- .../mobile/src/screens/ConversationScreen.tsx | 37 ++++- .../mobile/src/screens/forms.behavior.tsx | 9 ++ .../mobile/src/screens/session.behavior.tsx | 149 +++++++++++++++++- packages/mobile/src/settings.test.ts | 102 +++++++++++- packages/mobile/src/settings.ts | 49 +++++- packages/mobile/src/useConversation.test.ts | 129 +++++++++++++-- packages/mobile/src/useConversation.ts | 45 +++++- 8 files changed, 490 insertions(+), 32 deletions(-) diff --git a/packages/mobile/src/api.ts b/packages/mobile/src/api.ts index 47bd59be1d3..1cf14820ee4 100644 --- a/packages/mobile/src/api.ts +++ b/packages/mobile/src/api.ts @@ -14,7 +14,7 @@ type SchemaClient = T extends { ? Client, InferSchemaOutput, Error> : { [K in keyof T]: SchemaClient }; export type MobileClient = SchemaClient< - Pick + Pick >; export interface MobileConnection { client: MobileClient; diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 4bfab3f4a11..575955baa81 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -21,7 +21,7 @@ import { ContextUsage } from "../components/ContextUsage"; import { getContextMeterData } from "../contextUsage"; import { useConversation } from "../useConversation"; import { linkedAbortController } from "../useConnection"; -import { modelName, resolveSettings } from "../settings"; +import { getPolicyBlockReason, modelName, resolveSettings } from "../settings"; import type { ChatSettings } from "../settings"; import { ModelSettings } from "./ModelSettings"; import { colors, fontFamily, layout, radii, spacing, typography } from "../theme"; @@ -97,6 +97,13 @@ export function ConversationScreen(props: { const context = getContextMeterData(transcript.messages, options, settings?.providers); const ready = props.connected && !props.signal.aborted && transcript.caughtUp && !error && settings !== null; + const policyBlockReason = + settings && options ? getPolicyBlockReason(settings, options.model) : null; + const canAct = ready && !policyBlockReason; + const latestSettings = useRef({ options, policyBlockReason }); + useEffect(() => { + latestSettings.current = { options, policyBlockReason }; + }, [options, policyBlockReason]); const running = ready && transcript.streaming; const expanded = inputFocused || draft.length > 0 || running || showSettings !== null; @@ -109,10 +116,10 @@ export function ConversationScreen(props: { ? lastMessage : undefined; const canResume = - ready && !running && resumeMessageId === lastMessage?.id && lastMessage?.metadata?.partial; + canAct && !running && resumeMessageId === lastMessage?.id && lastMessage?.metadata?.partial; async function send() { - if (!ready || !options?.model || !draft.trim() || pending.current || running) return; + if (!canAct || !options?.model || !draft.trim() || pending.current || running) return; pending.current = true; setBusy(true); setActionError(null); @@ -181,10 +188,14 @@ export function ConversationScreen(props: { !latest.metadata?.partial ) return; + const { options, policyBlockReason } = latestSettings.current; if (!options?.model) return; // The answer is already durable and its form may disappear on tool-call-end. // Keep resume failures outside that form, and retry only resume, never the answer. setResumeMessageId(messageId); + // A policy update can arrive while the answer is being saved. Keep the resume + // recovery affordance, but never start a newly prohibited turn. + if (policyBlockReason) return; try { const result = await props.client.workspace.resumeStream( { workspaceId: props.workspace.id, options }, @@ -223,6 +234,7 @@ export function ConversationScreen(props: { async function answer(toolCallId: string, answers: Record) { if (!ready) throw new Error("Reconnect before answering."); + if (policyBlockReason) throw new Error(policyBlockReason); if (pending.current) throw new Error("Another action is in progress."); if ( !answerMessage || @@ -328,7 +340,7 @@ export function ConversationScreen(props: { message={item} streaming={transcript.streamingMessageId === item.id && running} canAnswer={ - ready && !busy && answerMessage?.id === item.id && resumeMessageId !== item.id + canAct && !busy && answerMessage?.id === item.id && resumeMessageId !== item.id } onAnswer={answer} /> @@ -373,6 +385,11 @@ export function ConversationScreen(props: { style={styles.composerWrap} onLayout={(event) => setComposerHeight(event.nativeEvent.layout.height)} > + {policyBlockReason && ( + + {policyBlockReason} + + )} {actionError && ( { @@ -470,7 +487,7 @@ export function ConversationScreen(props: { diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index a5b97aa8825..4f1c99a39a7 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -324,6 +324,7 @@ const pickerValue: ChatSettings = { }, }; const pickerData: SettingsData = { + policy: { source: "none", status: { state: "disabled" }, policy: null }, config: { agentAiDefaults: {}, defaultModel: "local:one", hiddenModels: ["other:hidden"] }, providers: { local: { @@ -400,6 +401,14 @@ test.each([ const client = createORPCClient({ call: async (path, input, request) => { switch (path.join(".")) { + case "policy.get": + return pickerData.policy; + case "policy.onChanged": + return new ReadableStream({ + start(controller) { + request.signal?.addEventListener("abort", () => controller.close(), { once: true }); + }, + }).values(); case "config.getConfig": return { agentAiDefaults: {}, diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index e7708e1b1d0..c20d06a8196 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -32,7 +32,13 @@ function deferred() { return { promise, resolve, reject }; } -function fixture(messages: WorkspaceChatMessage[] = [], wide = false) { +type Policy = Awaited>; +const disabledPolicy: Policy = { source: "none", status: { state: "disabled" }, policy: null }; +function fixture( + messages: WorkspaceChatMessage[] = [], + wide = false, + initialPolicy: Policy | Error = disabledPolicy +) { Object.defineProperty(document.documentElement, "clientWidth", { configurable: true, value: wide ? 1200 : 375, @@ -45,6 +51,8 @@ function fixture(messages: WorkspaceChatMessage[] = [], wide = false) { end: () => void; }> = []; const calls: Array<{ path: string; input: unknown; signal?: AbortSignal }> = []; + let policy = initialPolicy; + const policyEvents: ReadableStreamDefaultController[] = []; let closed = 0; let reconnected = 0; let disconnected = 0; @@ -80,10 +88,22 @@ function fixture(messages: WorkspaceChatMessage[] = [], wide = false) { return workspaces; case "projects.list": return []; + case "policy.get": + if (policy instanceof Error) throw policy; + return policy; + case "policy.onChanged": + return events(options.signal, [], (controller) => policyEvents.push(controller)); case "config.getConfig": return { agentAiDefaults: {}, defaultModel: model }; case "providers.getConfig": - return {}; + return { + anthropic: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: ["allowed"], + }, + }; case "agents.list": return [ { id: "exec", name: "Exec", uiSelectable: true }, @@ -166,6 +186,10 @@ function fixture(messages: WorkspaceChatMessage[] = [], wide = false) { ).not.toBe("true") ); }, + async updatePolicy(next: Policy) { + policy = next; + await act(async () => policyEvents.at(-1)!.enqueue()); + }, async emit(event: WorkspaceChatMessage) { await act(async () => chats.at(-1)!.events.enqueue(event)); }, @@ -523,3 +547,124 @@ test("reconnect cancels the old answer's resume continuation and reconciles pend expect(callCount(view, "answerAskUserQuestion")).toBe(2); expect(callCount(view, "resumeStream")).toBe(1); }); + +test("blocked initial model stays selected and editable while a permitted choice unlocks sending", async () => { + const view = fixture([], false, { + source: "env", + status: { state: "enforced" }, + policy: { + policyFormatVersion: "0.1", + providerAccess: [{ id: "anthropic", allowedModels: ["allowed"] }], + mcp: { allowUserDefined: { stdio: true, remote: true } }, + runtimes: null, + }, + }); + await view.select("alpha"); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Keep draft" } }); + expect(view.getByRole("alert")).toBeDefined(); + expect(view.getByRole("button", { name: "Send message" }).getAttribute("aria-disabled")).toBe( + "true" + ); + fireEvent.click(view.getByRole("button", { name: "Send message" })); + expect(callCount(view, "sendMessage")).toBe(0); + fireEvent.click(view.getByRole("button", { name: "Choose model" })); + expect(view.getByRole("radio", { name: model }).getAttribute("aria-checked")).toBe("true"); + fireEvent.click(view.getByRole("radio", { name: "anthropic:allowed" })); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + expect(callCount(view, "sendMessage")).toBe(1); + expect(view.calls.find((call) => call.path === "workspace.sendMessage")?.input).toMatchObject({ + message: "Keep draft", + options: { model: "anthropic:allowed" }, + }); +}); + +test("server minimum-client block disables answers and sending until live policy recovery", async () => { + const blocked: Policy = { + source: "env", + status: { state: "blocked", reason: "minimum_client_version requires server upgrade" }, + policy: null, + }; + const view = fixture([question()], false, blocked); + await view.select("alpha"); + expect(view.getByRole("alert").textContent).toContain(blocked.status.reason!); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Retained" } }); + fireEvent.click(view.getByRole("button", { name: "Send message" })); + expect(callCount(view, "sendMessage")).toBe(0); + expect(view.getByRole("button", { name: "Send answers" }).getAttribute("aria-disabled")).toBe( + "true" + ); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(callCount(view, "answerAskUserQuestion")).toBe(0); + await view.updatePolicy(disabledPolicy); + await waitFor(() => expect(view.queryByRole("alert")).toBeNull()); + await submitAnswer(view); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(1); +}); + +test("policy changes during a saved answer prevent automatic resume and permit resume-only recovery", async () => { + const view = fixture([question()]); + await view.select("alpha"); + const pending = deferred(); + view.setAnswer(() => pending.promise); + await submitAnswer(view); + await view.updatePolicy({ + source: "env", + status: { state: "blocked", reason: "upgrade required" }, + policy: null, + }); + await act(async () => pending.resolve({ success: true })); + expect(callCount(view, "resumeStream")).toBe(0); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + await view.updatePolicy(disabledPolicy); + await act(async () => fireEvent.click(await view.findByRole("button", { name: "Resume agent" }))); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(1); +}); + +test("an initial policy read failure exposes retry and preserves model access and draft without permitting a send", async () => { + const view = fixture([], false, new Error("policy unavailable")); + await view.select("alpha"); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Keep me" } }); + expect(view.getByRole("alert")).toBeDefined(); + expect(view.getByRole("button", { name: "Choose model" }).getAttribute("aria-disabled")).not.toBe( + "true" + ); + fireEvent.click(view.getByRole("button", { name: "Send message" })); + expect(callCount(view, "sendMessage")).toBe(0); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Retry" }))); + await waitFor(() => + expect(view.calls.filter((call) => call.path === "policy.get")).toHaveLength(2) + ); + expect(view.reconnected).toBe(1); + await view.updatePolicy(disabledPolicy); + await waitFor(() => expect(view.queryByRole("alert")).toBeNull()); + expect(view.getByLabelText("Message")).toHaveProperty("value", "Keep me"); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + expect(callCount(view, "sendMessage")).toBe(1); +}); + +test("answer continuation resumes with the same latest selection that policy permits", async () => { + const view = fixture([question()]); + await view.select("alpha"); + const pending = deferred(); + view.setAnswer(() => pending.promise); + await submitAnswer(view); + fireEvent.click(view.getByRole("button", { name: "Choose model" })); + fireEvent.click(view.getByRole("radio", { name: "anthropic:allowed" })); + await view.updatePolicy({ + source: "env", + status: { state: "enforced" }, + policy: { + policyFormatVersion: "0.1", + providerAccess: [{ id: "anthropic", allowedModels: ["allowed"] }], + mcp: { allowUserDefined: { stdio: true, remote: true } }, + runtimes: null, + }, + }); + await act(async () => pending.resolve({ success: true })); + expect(callCount(view, "resumeStream")).toBe(1); + expect(view.calls.find((call) => call.path === "workspace.resumeStream")?.input).toMatchObject({ + options: { model: "anthropic:allowed" }, + }); +}); diff --git a/packages/mobile/src/settings.test.ts b/packages/mobile/src/settings.test.ts index 6481121e7e3..6defa9ac038 100644 --- a/packages/mobile/src/settings.test.ts +++ b/packages/mobile/src/settings.test.ts @@ -1,10 +1,17 @@ import { describe, expect, test } from "bun:test"; import { KNOWN_MODELS } from "../../../src/common/constants/knownModels"; -import { modelChoices, modelMatchesSearch, resolveSettings, type SettingsData } from "./settings"; +import { + getPolicyBlockReason, + modelChoices, + modelMatchesSearch, + resolveSettings, + type SettingsData, +} from "./settings"; function data(): SettingsData { return { config: { agentAiDefaults: {} }, + policy: { source: "none", status: { state: "disabled" }, policy: null }, providers: { anthropic: { isConfigured: true, isEnabled: true, apiKeySet: true }, openai: { isConfigured: true, isEnabled: false, apiKeySet: true }, @@ -14,6 +21,99 @@ function data(): SettingsData { }; } +describe("server policy", () => { + test("keeps a denied current selection visible without silently replacing it", () => { + const settings = data(); + const current = KNOWN_MODELS.SONNET.id; + settings.policy = { + source: "env", + status: { state: "enforced" }, + policy: { + policyFormatVersion: "0.1", + providerAccess: [{ id: "anthropic", allowedModels: ["allowed"] }], + mcp: { allowUserDefined: { stdio: true, remote: true } }, + runtimes: null, + }, + }; + settings.config.defaultModel = current; + settings.providers.anthropic.models = ["allowed"]; + expect(resolveSettings({}, settings, "exec").model).toBe(current); + expect(modelChoices(settings, current)).toEqual([current, "anthropic:allowed"]); + expect(modelChoices(settings, "")).not.toContain(current); + expect(getPolicyBlockReason(settings, current)).not.toBeNull(); + expect(getPolicyBlockReason(settings, "anthropic:allowed")).toBeNull(); + }); + + test("checks resolved gateway identity and falls back to an allowed direct route", () => { + const settings = data(); + const model = "openai:gpt-4o"; + settings.providers.openai = { + isEnabled: true, + isConfigured: true, + apiKeySet: true, + models: ["gpt-4o"], + }; + settings.providers.coder = { + isEnabled: true, + isConfigured: true, + apiKeySet: false, + models: ["openai/gpt-4o"], + }; + settings.config.routePriority = ["coder", "direct"]; + settings.policy = { + source: "env", + status: { state: "enforced" }, + policy: { + policyFormatVersion: "0.1", + providerAccess: [{ id: "coder", allowedModels: ["openai/gpt-4o"] }], + mcp: { allowUserDefined: { stdio: true, remote: true } }, + runtimes: null, + }, + }; + expect(modelChoices(settings, "")).toContain(model); + expect(getPolicyBlockReason(settings, model)).toBeNull(); + settings.config.routeOverrides = { [model]: "direct" }; + expect(modelChoices(settings, "")).not.toContain(model); + expect(getPolicyBlockReason(settings, model)).not.toBeNull(); + settings.config.routeOverrides = {}; + settings.policy.policy!.providerAccess = [{ id: "openai", allowedModels: ["gpt-4o"] }]; + expect(modelChoices(settings, "")).toContain(model); + expect(getPolicyBlockReason(settings, model)).toBeNull(); + settings.providers.openai.isEnabled = false; + expect(modelChoices(settings, "")).not.toContain(model); + settings.policy.policy!.providerAccess = [{ id: "anthropic" }]; + expect(getPolicyBlockReason(settings, model)).not.toBeNull(); + }); + + test("uses backend blocked status rather than independently comparing mobile versions", () => { + const settings = data(); + settings.policy = { + source: "env", + status: { state: "blocked", reason: "minimum_client_version 999 required by server" }, + policy: null, + }; + expect(getPolicyBlockReason(settings, KNOWN_MODELS.SONNET.id)).toBe( + settings.policy.status.reason! + ); + settings.policy.status.reason = ""; + expect(getPolicyBlockReason(settings, KNOWN_MODELS.SONNET.id)).toBeTruthy(); + settings.policy = null; + expect(getPolicyBlockReason(settings, KNOWN_MODELS.SONNET.id)).not.toBeNull(); + settings.policy = { source: "env", status: { state: "enforced" }, policy: null }; + expect(getPolicyBlockReason(settings, KNOWN_MODELS.SONNET.id)).not.toBeNull(); + settings.policy.policy = { + policyFormatVersion: "0.1", + minimumClientVersion: "999.0.0", + providerAccess: null, + mcp: { allowUserDefined: { stdio: true, remote: true } }, + runtimes: null, + }; + expect(getPolicyBlockReason(settings, KNOWN_MODELS.SONNET.id)).toBeNull(); + settings.policy = { source: "none", status: { state: "disabled" }, policy: null }; + expect(getPolicyBlockReason(settings, KNOWN_MODELS.SONNET.id)).toBeNull(); + }); +}); + describe("mobile model settings", () => { test("exposes built-ins for configured providers even without a custom catalog", () => { const options = modelChoices(data(), ""); diff --git a/packages/mobile/src/settings.ts b/packages/mobile/src/settings.ts index 60998b0ce18..668f30ac08f 100644 --- a/packages/mobile/src/settings.ts +++ b/packages/mobile/src/settings.ts @@ -10,12 +10,18 @@ import { targetWorkspaceBucketToLayer } from "../../../src/common/types/agentAiS import { normalizeToCanonical } from "../../../src/common/utils/ai/models"; import { formatModelDisplayName } from "../../../src/common/utils/ai/modelDisplay"; import { isProviderModelAccessibleFromAuthoritativeCatalog } from "../../../src/common/utils/providers/gatewayModelCatalog"; +import { + isGatewayModelAccessibleForUi, + isModelAllowedByPolicy, +} from "../../../src/browser/utils/policyUi"; import type { MobileClient } from "./api"; import type { FrontendWorkspaceMetadata } from "../../../src/common/types/workspace"; import type { SendMessageOptions } from "../../../src/common/orpc/types"; import type { ThinkingLevel } from "../../../src/common/types/thinking"; export type SettingsData = { + // Null is unavailable, never an implicit policy-disabled fallback. + policy: Awaited> | null; config: Pick< Awaited>, | "agentAiDefaults" @@ -76,6 +82,37 @@ export function resolveSettings( }; } +function modelRouting(data: SettingsData) { + const policy = data.policy?.status.state === "enforced" ? data.policy.policy : null; + return { + policy, + isConfigured: (provider: string) => + data.providers[provider]?.isConfigured === true && + data.providers[provider]?.isEnabled !== false, + isAccessible: (gateway: string, modelId: string) => + isGatewayModelAccessibleForUi(policy, data.providers, gateway, modelId), + }; +} + +export function getPolicyBlockReason(data: SettingsData, model: string): string | null { + if (!data.policy || (data.policy.status.state === "enforced" && !data.policy.policy)) + return "Server policy unavailable. Retry to reload it."; + // The server owns minimum-client/version semantics, including its blocked reason. + if (data.policy.status.state === "blocked") + return data.policy.status.reason || "Blocked by server policy."; + const { policy, isConfigured, isAccessible } = modelRouting(data); + const route = resolveRoute( + model, + data.config.routePriority ?? ["direct"], + data.config.routeOverrides ?? {}, + isConfigured, + isAccessible + ); + return isModelAllowedByPolicy(policy, `${route.routeProvider}:${route.routeModelId}`) + ? null + : "The selected model's route is blocked by server policy. Choose another model."; +} + export function modelChoices(data: SettingsData, currentModel: string): string[] { const models = new Set(); if (currentModel) models.add(currentModel); @@ -88,10 +125,8 @@ export function modelChoices(data: SettingsData, currentModel: string): string[] } } for (const model of Object.values(KNOWN_MODELS)) models.add(model.id); - const isConfigured = (provider: string) => - data.providers[provider]?.isConfigured === true && - data.providers[provider]?.isEnabled !== false; - const isAccessible = (provider: string, modelId: string) => { + const { isConfigured, isAccessible } = modelRouting(data); + const isAuthoritativeModelAccessible = (provider: string, modelId: string) => { const config = data.providers[provider]; return isProviderModelAccessibleFromAuthoritativeCatalog( provider, @@ -106,7 +141,11 @@ export function modelChoices(data: SettingsData, currentModel: string): string[] if (model === currentModel) return true; if (data.config.hiddenModels?.includes(model)) return false; const colon = model.indexOf(":"); - if (!isAccessible(model.slice(0, colon), model.slice(colon + 1))) return false; + if (!isAuthoritativeModelAccessible(model.slice(0, colon), model.slice(colon + 1))) + return false; + // Keep Settings available during a global block; model restrictions follow the + // resolved route, not the canonical identity (gateways own their credentials/policy). + if (data.policy?.status.state === "enforced" && getPolicyBlockReason(data, model)) return false; if ( !isModelAvailable( model, diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts index 633b304bd28..2555030010d 100644 --- a/packages/mobile/src/useConversation.test.ts +++ b/packages/mobile/src/useConversation.test.ts @@ -18,22 +18,44 @@ function message(sequence: number, text = `message ${sequence}`): WorkspaceChatM }; } -function fixture() { +type Policy = Awaited>; +const disabledPolicy: Policy = { source: "none", status: { state: "disabled" }, policy: null }; + +function fixture(getPolicy: () => Promise = async () => disabledPolicy) { type Page = Awaited>; let complete!: (page: Page) => void; const page = new Promise((resolve) => { complete = resolve; }); let eventController!: ReadableStreamDefaultController; - const events = new ReadableStream({ - start(controller) { - eventController = controller; - }, - }); + const policyRequests: AbortSignal[] = []; + const policySubscriptions: Array<{ + signal: AbortSignal; + events: ReadableStreamDefaultController; + fail: (error: Error) => void; + }> = []; const requests: Array<{ input: unknown; signal?: AbortSignal }> = []; const client = createORPCClient({ call: async (path, input, options) => { switch (path.join(".")) { + case "policy.get": + policyRequests.push(options.signal!); + return getPolicy(); + case "policy.onChanged": + return new ReadableStream({ + start(controller) { + const close = () => controller.close(); + policySubscriptions.push({ + signal: options.signal!, + events: controller, + fail(error) { + options.signal?.removeEventListener("abort", close); + controller.error(error); + }, + }); + options.signal?.addEventListener("abort", close, { once: true }); + }, + }).values(); case "config.getConfig": return { agentAiDefaults: {} }; case "providers.getConfig": @@ -41,8 +63,12 @@ function fixture() { case "agents.list": return []; case "workspace.onChat": - options.signal?.addEventListener("abort", () => eventController.close(), { once: true }); - return events.values(); + return new ReadableStream({ + start(controller) { + eventController = controller; + options.signal?.addEventListener("abort", () => controller.close(), { once: true }); + }, + }).values(); case "workspace.history.loadMore": requests.push({ input, signal: options.signal }); return page; @@ -52,12 +78,19 @@ function fixture() { }, }); const lifetime = new AbortController(); - const view = renderHook(() => useConversation(client, "workspace", lifetime.signal)); + const view = renderHook( + ({ workspaceId }) => useConversation(client, workspaceId, lifetime.signal), + { initialProps: { workspaceId: "workspace" } } + ); return { ...view, complete, requests, + policyRequests, + policySubscriptions, + lifetime, async ready() { + await waitFor(() => expect(eventController).toBeDefined()); await act(async () => { eventController.enqueue(message(10, "current")); eventController.enqueue({ type: "caught-up", hasOlderHistory: true }); @@ -123,3 +156,81 @@ test("switching away aborts an in-flight page and duplicate taps do not start an view.complete({ messages: [message(2)], nextCursor: null, hasOlder: false }); await pending; }); + +test("initial policy and live changes are loaded once per event and abort with their scope", async () => { + let policy: Policy = { + source: "env", + status: { state: "blocked", reason: "upgrade required" }, + policy: null, + }; + const view = fixture(async () => policy); + await view.ready(); + await waitFor(() => expect(view.result.current.settings?.policy).toEqual(policy)); + expect(view.policyRequests).toHaveLength(1); + policy = disabledPolicy; + await act(async () => view.policySubscriptions[0].events.enqueue()); + await waitFor(() => expect(view.result.current.settings?.policy).toEqual(disabledPolicy)); + expect(view.policyRequests).toHaveLength(2); + view.unmount(); + expect(view.policySubscriptions[0].signal.aborted).toBe(true); + expect(view.policyRequests.every((signal) => signal.aborted)).toBe(true); +}); + +test("a failed policy read stays unavailable without hiding settings and a change can recover it", async () => { + let fail = true; + const view = fixture(async () => { + if (fail) throw new Error("policy fetch failed"); + return disabledPolicy; + }); + await view.ready(); + await waitFor(() => expect(view.policyRequests).toHaveLength(1)); + expect(view.result.current.settings).not.toBeNull(); + expect(view.result.current.settings?.policy).toBeNull(); + fail = false; + await act(async () => view.policySubscriptions[0].events.enqueue()); + await waitFor(() => expect(view.result.current.settings?.policy).toEqual(disabledPolicy)); + await act(async () => view.policySubscriptions[0].fail(new Error("subscription lost"))); + await waitFor(() => expect(view.result.current.settings?.policy).toBeNull()); +}); + +test("a late policy response cannot update an aborted connection lifetime", async () => { + let resolve!: (policy: Policy) => void; + const view = fixture( + () => + new Promise((done) => { + resolve = done; + }) + ); + await view.ready(); + await waitFor(() => expect(view.policyRequests).toHaveLength(1)); + act(() => view.lifetime.abort()); + await act(async () => resolve(disabledPolicy)); + expect(view.result.current.settings?.policy).toBeNull(); + expect(view.policySubscriptions[0].signal.aborted).toBe(true); +}); + +test("switching workspace cancels old policy scope and ignores its late snapshot", async () => { + let resolve!: (policy: Policy) => void; + let first = true; + const blocked: Policy = { + source: "env", + status: { state: "blocked", reason: "upgrade required" }, + policy: null, + }; + const view = fixture(() => { + if (!first) return Promise.resolve(blocked); + first = false; + return new Promise((done) => { + resolve = done; + }); + }); + await view.ready(); + view.rerender({ workspaceId: "other" }); + await waitFor(() => expect(view.result.current.settings?.policy).toEqual(blocked)); + expect(view.policySubscriptions).toHaveLength(2); + expect(view.policySubscriptions[0].signal.aborted).toBe(true); + expect(view.policyRequests[0].aborted).toBe(true); + await act(async () => resolve(disabledPolicy)); + expect(view.result.current.settings?.policy).toEqual(blocked); + expect(view.policySubscriptions[1].signal.aborted).toBe(false); +}); diff --git a/packages/mobile/src/useConversation.ts b/packages/mobile/src/useConversation.ts index 660f0db3a8f..58a460899c6 100644 --- a/packages/mobile/src/useConversation.ts +++ b/packages/mobile/src/useConversation.ts @@ -6,9 +6,10 @@ import { linkedAbortController } from "./useConnection"; export function useConversation(client: MobileClient, workspaceId: string, signal: AbortSignal) { const [transcript, setTranscript] = useState(createTranscriptState); - const [settings, setSettings] = useState(null); + const [settings, setSettings] = useState | null>(null); + const [policy, setPolicy] = useState(null); const [error, setError] = useState(null); - const [owner, setOwner] = useState(() => client); + const [owner, setOwner] = useState(() => ({ client, workspaceId, signal })); const [loadingOlder, setLoadingOlder] = useState(false); const [historyError, setHistoryError] = useState(null); const historyRequest = useRef(null); @@ -20,12 +21,37 @@ export function useConversation(client: MobileClient, workspaceId: string, signa const controller = linkedAbortController(signal); setTranscript(createTranscriptState()); setSettings(null); + setPolicy(null); setError(null); - setOwner(() => client); + setOwner({ client, workspaceId, signal }); setLoadingOlder(false); setHistoryError(null); historyCursor.current = null; if (signal.aborted) return; + async function subscribePolicy() { + // Subscribe before the initial read so changes during that read are not lost. + const events = await client.policy.onChanged(undefined, { signal: controller.signal }); + async function refresh() { + if (controller.signal.aborted) return; + setPolicy(null); + try { + const next = await client.policy.get(undefined, { signal: controller.signal }); + if (!controller.signal.aborted) setPolicy(next); + } catch { + // Fail closed, but keep the subscription alive so a later change can heal it. + } + } + await refresh(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Notifications have no payload. + for await (const _ of events) { + if (controller.signal.aborted) return; + await refresh(); + } + if (!controller.signal.aborted) setPolicy(null); + } + subscribePolicy().catch(() => { + if (!controller.signal.aborted) setPolicy(null); + }); async function subscribe() { const [config, providers, agents] = await Promise.all([ client.config.getConfig(undefined, { signal: controller.signal }), @@ -68,9 +94,14 @@ export function useConversation(client: MobileClient, workspaceId: string, signa historyRequest.current = null; }; }, [client, workspaceId, signal]); + const owned = + owner.client === client && + owner.workspaceId === workspaceId && + owner.signal === signal && + !signal.aborted; async function loadOlder() { if ( - owner !== client || + !owned || signal.aborted || !transcript.caughtUp || !transcript.hasOlderHistory || @@ -126,9 +157,9 @@ export function useConversation(client: MobileClient, workspaceId: string, signa // A replacement client must never inherit the old socket’s caught-up flag, even // for the render before the subscription effect runs. Draft state lives above this hook. return { - transcript: owner === client ? transcript : createTranscriptState(), - settings: owner === client ? settings : null, - error: owner === client ? error : null, + transcript: owned ? transcript : createTranscriptState(), + settings: owned && settings ? { ...settings, policy } : null, + error: owned ? error : null, loadingOlder, historyError, loadOlder, From 6640ae878170e5befb8b4d7041b9467225407b66 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 15:41:23 +0000 Subject: [PATCH 28/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20keep=20cust?= =?UTF-8?q?om=20question=20option=20identities=20distinct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent a tool option labeled other from sharing a React key with the implicit custom-answer option. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$869.55`_ --- packages/mobile/src/components/Message.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index 35ab1c2b798..cc1af3664a1 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -286,7 +286,7 @@ function QuestionForm(props: { const checked = draft.selected.includes(option.label); return ( Date: Mon, 7 Sep 2026 15:47:46 +0000 Subject: [PATCH 29/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20pin=20share?= =?UTF-8?q?d=20schema=20runtime=20for=20production=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metro resolves mobile dependencies first, so Expo's transitive Zod 3 shadowed the shared schemas' Zod 4 and crashed before login at .meta(). Declare Zod 4 explicitly and document the resolver boundary. Validation: reproduced blank login in the production export; corrected immutable export passes all three mobile browser E2Es. Mobile checks (88 pass, 1 opt-in skip), iOS export, and root static checks pass. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$877.12`_ --- packages/mobile/bun.lock | 5 ++++- packages/mobile/metro.config.cjs | 1 + packages/mobile/package.json | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/mobile/bun.lock b/packages/mobile/bun.lock index a1d78cd6a18..9266ff9995a 100644 --- a/packages/mobile/bun.lock +++ b/packages/mobile/bun.lock @@ -23,6 +23,7 @@ "react-native-web": "~0.21.0", "react-native-worklets": "0.10.1", "web-streams-polyfill": "4.2.0", + "zod": "4.4.3", }, "devDependencies": { "@orpc/contract": "1.14.11", @@ -1071,7 +1072,7 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1087,6 +1088,8 @@ "@expo/cli/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "@expo/cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "@expo/metro-runtime/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], diff --git a/packages/mobile/metro.config.cjs b/packages/mobile/metro.config.cjs index dfea7d311a3..d8ac1dd8384 100644 --- a/packages/mobile/metro.config.cjs +++ b/packages/mobile/metro.config.cjs @@ -5,6 +5,7 @@ const config = getDefaultConfig(__dirname); const repositoryRoot = path.resolve(__dirname, "../.."); // Share protocol contracts, not the desktop React runtime or its DOM components. config.watchFolders = [repositoryRoot]; +// Pin shared schema runtimes in mobile dependencies so Expo's transitive versions cannot shadow them. config.resolver.nodeModulesPaths = [ path.join(__dirname, "node_modules"), path.join(repositoryRoot, "node_modules"), diff --git a/packages/mobile/package.json b/packages/mobile/package.json index a636a23b472..93cdea93669 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -24,7 +24,8 @@ "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", "react-native-worklets": "0.10.1", - "web-streams-polyfill": "4.2.0" + "web-streams-polyfill": "4.2.0", + "zod": "4.4.3" }, "devDependencies": { "@orpc/contract": "1.14.11", From 795b5a578c6ace105ebcc8d7470223f9b0b47c26 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 16:13:54 +0000 Subject: [PATCH 30/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20preserve=20?= =?UTF-8?q?active=20context=20capacity=20and=20deleted=20file=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the active message model until streaming settles so next-turn model selection cannot relabel current context usage. Keep deleted-file headers readable by falling back to the old-side Git path before hiding diff metadata. Validation: both red regressions reproduced, targeted tests now pass; complete mobile and root static checks pass. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$888.91`_ --- packages/mobile/src/contextUsage.test.ts | 46 +++++++++++++++++++ packages/mobile/src/contextUsage.ts | 9 +++- packages/mobile/src/screens/ChangesScreen.tsx | 12 +++-- .../mobile/src/screens/ConversationScreen.tsx | 7 ++- .../mobile/src/screens/forms.behavior.tsx | 33 +++++++++++++ 5 files changed, 100 insertions(+), 7 deletions(-) diff --git a/packages/mobile/src/contextUsage.test.ts b/packages/mobile/src/contextUsage.test.ts index fb65c1f83d9..e869c3c4acd 100644 --- a/packages/mobile/src/contextUsage.test.ts +++ b/packages/mobile/src/contextUsage.test.ts @@ -145,3 +145,49 @@ test("context capacity follows per-model 1M intent without bypassing privacy or 1_000_000 ); }); + +test("active stream capacity survives model selection changes until the stream settles", () => { + const activeModel = "anthropic:claude-sonnet-4-20250514"; + const selectedModel = "openai:gpt-4o"; + const providers = { + anthropic: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: [{ id: "claude-sonnet-4-20250514", contextWindowTokens: 200_000 }], + }, + openai: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: [{ id: "gpt-4o", contextWindowTokens: 400_000 }], + }, + }; + const options = { + model: selectedModel, + agentId: "exec", + providerOptions: { anthropic: { use1MContextModels: [activeModel] } }, + }; + const contextUsage = { inputTokens: 200_000, outputTokens: 0, totalTokens: 200_000 }; + let state = applyChatEvent(createTranscriptState(), { type: "message", ...row }); + state = applyChatEvent(state, { ...start, model: activeModel }); + const current = () => + getContextMeterData(state.messages, options, providers, state.streamingMessageId); + // Even before fresh usage arrives, the stream-start metadata owns the capacity. + expect(current().maxTokens).toBe(1_000_000); + state = applyChatEvent(state, { ...delta, usage: contextUsage }); + expect(current().totalPercentage).toBe(20); + options.model = activeModel; + expect(current().totalPercentage).toBe(20); + options.model = selectedModel; + expect(current().totalPercentage).toBe(20); + state = applyChatEvent(state, { + type: "stream-end", + workspaceId: "w", + messageId: "b", + parts: [], + metadata: { model: activeModel, contextUsage }, + }); + expect(current().maxTokens).toBe(400_000); + expect(current().totalPercentage).toBe(50); +}); diff --git a/packages/mobile/src/contextUsage.ts b/packages/mobile/src/contextUsage.ts index 5c12387d0e0..d2f36510d0f 100644 --- a/packages/mobile/src/contextUsage.ts +++ b/packages/mobile/src/contextUsage.ts @@ -33,9 +33,14 @@ export function getContextUsage(messages: MuxMessage[], model: string) { export function getContextMeterData( messages: MuxMessage[], options: ChatSettings | null, - providers?: SettingsData["providers"] + providers?: SettingsData["providers"], + streamingMessageId?: string | null ) { - const model = options?.model ?? "unknown"; + // A picker change targets the next request, not the turn still using this context. + const activeModel = streamingMessageId + ? messages.find((message) => message.id === streamingMessageId)?.metadata?.model + : undefined; + const model = activeModel ?? options?.model ?? "unknown"; const anthropic = options?.providerOptions?.anthropic; const canonical = normalizeToCanonical(model); const metadataModel = resolveModelForMetadata(model, providers ?? null); diff --git a/packages/mobile/src/screens/ChangesScreen.tsx b/packages/mobile/src/screens/ChangesScreen.tsx index ac8857757dc..0d2e0d2cdbc 100644 --- a/packages/mobile/src/screens/ChangesScreen.tsx +++ b/packages/mobile/src/screens/ChangesScreen.tsx @@ -110,11 +110,15 @@ function ProjectDiff(props: { data: Extract { const lines = file.trimEnd().split("\n"); + const newPath = lines.find((line) => line.startsWith("+++ "))?.slice(4); + // Deletions have no new-side path; retain the old filename before hiding diff headers. const filename = - lines - .find((line) => line.startsWith("+++ ")) - ?.slice(4) - .replace(/^b\//, "") ?? lines[0].replace(/^diff --git /, ""); + (newPath === "/dev/null" + ? lines + .find((line) => line.startsWith("--- ")) + ?.slice(4) + .replace(/^a\//, "") + : newPath?.replace(/^b\//, "")) ?? lines[0].replace(/^diff --git /, ""); const content = lines.filter((line) => !/^(diff --git |index |--- |\+\+\+ )/.test(line)); const additions = content.filter((line) => line.startsWith("+")).length; const deletions = content.filter((line) => line.startsWith("-")).length; diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 575955baa81..83047e635fe 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -94,7 +94,12 @@ export function ConversationScreen(props: { props.selection ) : null; - const context = getContextMeterData(transcript.messages, options, settings?.providers); + const context = getContextMeterData( + transcript.messages, + options, + settings?.providers, + transcript.streamingMessageId + ); const ready = props.connected && !props.signal.aborted && transcript.caughtUp && !error && settings !== null; const policyBlockReason = diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 4f1c99a39a7..a941820ad54 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -86,6 +86,39 @@ test("changes include secondary repositories in one request and do not hide fail expect(view.queryByText("No uncommitted changes")).toBeNull(); }); +test("changes keep distinct deleted paths while additions use the new-side path", async () => { + const diffs = [ + "diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-deleted\n", + "diff --git a/old file.ts b/old file.ts\n--- a/old file.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-also deleted\n", + "diff --git a/new.ts b/new.ts\n--- /dev/null\n+++ b/new.ts\n@@ -0,0 +1 @@\n+added\n", + ]; + const client = createORPCClient({ + call: async () => [ + { + projectName: "Project", + projectPath: "/project", + success: true, + data: { diff: diffs.join(""), truncated: false }, + }, + ], + }); + const view = render( + {}} + onBack={() => {}} + /> + ); + await waitFor(() => expect(view.getByText("removed.ts")).toBeDefined()); + expect(view.getByText("old file.ts")).toBeDefined(); + expect(view.getByText("new.ts")).toBeDefined(); + expect(view.queryByText("/dev/null")).toBeNull(); + expect(view.getByText("-deleted")).toBeDefined(); + expect(view.getByText("+added")).toBeDefined(); +}); + test("context meter exposes measured progress without inventing an unknown percentage", () => { const data = { segments: [], totalTokens: 200_000, maxTokens: 1_000_000, totalPercentage: 20 }; const view = render(); From 3cf93ccbde35e3b2b77c070bd790274ad47f10c0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 16:17:27 +0000 Subject: [PATCH 31/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20refresh=20c?= =?UTF-8?q?onversation=20settings=20from=20live=20config=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PRRT_kwDOPxxmWM6f-WZw by consuming config/provider notifications before initial reads, cancelling obsolete snapshots, and gating actions while settings are unavailable. Retain answer recovery when a configuration reload overlaps its completion. Validate with mobile-check (94 pass, one real-server test skipped), web export, and iOS Hermes export. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$11.04`_ --- .../mobile/src/screens/ConversationScreen.tsx | 7 +- .../mobile/src/screens/forms.behavior.tsx | 2 + .../mobile/src/screens/session.behavior.tsx | 138 ++++++++++++- packages/mobile/src/useConversation.test.ts | 185 +++++++++++++++++- packages/mobile/src/useConversation.ts | 61 +++++- 5 files changed, 367 insertions(+), 26 deletions(-) diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 83047e635fe..2ba822b678d 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -194,13 +194,12 @@ export function ConversationScreen(props: { ) return; const { options, policyBlockReason } = latestSettings.current; - if (!options?.model) return; // The answer is already durable and its form may disappear on tool-call-end. // Keep resume failures outside that form, and retry only resume, never the answer. setResumeMessageId(messageId); - // A policy update can arrive while the answer is being saved. Keep the resume - // recovery affordance, but never start a newly prohibited turn. - if (policyBlockReason) return; + // Settings or policy can change while the answer is saved. Preserve recovery + // while unavailable, but never resume with stale options or a prohibited route. + if (!options?.model || policyBlockReason) return; try { const result = await props.client.workspace.resumeStream( { workspaceId: props.workspace.id, options }, diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index a941820ad54..f02331450d0 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -436,6 +436,8 @@ test.each([ switch (path.join(".")) { case "policy.get": return pickerData.policy; + case "config.onConfigChanged": + case "providers.onConfigChanged": case "policy.onChanged": return new ReadableStream({ start(controller) { diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index c20d06a8196..d1bb20c9cde 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -5,6 +5,7 @@ import { createORPCClient } from "@orpc/client"; import { ConnectedApp } from "../../App"; import type { Connection } from "./ConnectScreen"; import type { MobileClient } from "../api"; +import type { SettingsData } from "../settings"; import type { WorkspaceChatMessage } from "../transcript"; import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; @@ -51,6 +52,13 @@ function fixture( end: () => void; }> = []; const calls: Array<{ path: string; input: unknown; signal?: AbortSignal }> = []; + let config: SettingsData["config"] = { agentAiDefaults: {}, defaultModel: model }; + let providers: SettingsData["providers"] = { + anthropic: { isConfigured: true, isEnabled: true, apiKeySet: true, models: ["allowed"] }, + }; + const configEvents: ReadableStreamDefaultController[] = []; + const providerEvents: ReadableStreamDefaultController[] = []; + let configRead = async () => config; let policy = initialPolicy; const policyEvents: ReadableStreamDefaultController[] = []; let closed = 0; @@ -93,17 +101,14 @@ function fixture( return policy; case "policy.onChanged": return events(options.signal, [], (controller) => policyEvents.push(controller)); + case "config.onConfigChanged": + return events(options.signal, [], (controller) => configEvents.push(controller)); + case "providers.onConfigChanged": + return events(options.signal, [], (controller) => providerEvents.push(controller)); case "config.getConfig": - return { agentAiDefaults: {}, defaultModel: model }; + return configRead(); case "providers.getConfig": - return { - anthropic: { - isConfigured: true, - isEnabled: true, - apiKeySet: true, - models: ["allowed"], - }, - }; + return providers; case "agents.list": return [ { id: "exec", name: "Exec", uiSelectable: true }, @@ -186,6 +191,17 @@ function fixture( ).not.toBe("true") ); }, + setConfigRead(read: typeof configRead) { + configRead = read; + }, + async updateConfig(next: SettingsData["config"]) { + config = next; + await act(async () => configEvents.at(-1)!.enqueue()); + }, + async updateProviders(next: SettingsData["providers"]) { + providers = next; + await act(async () => providerEvents.at(-1)!.enqueue()); + }, async updatePolicy(next: Policy) { policy = next; await act(async () => policyEvents.at(-1)!.enqueue()); @@ -668,3 +684,107 @@ test("answer continuation resumes with the same latest selection that policy per options: { model: "anthropic:allowed" }, }); }); + +test("live privacy changes replace stale selection options for sending and answer continuation", async () => { + const view = fixture([question()]); + await view.select("alpha"); + fireEvent.click(view.getByRole("button", { name: "Choose model" })); + fireEvent.click(view.getByRole("radio", { name: "anthropic:allowed" })); + const providerOptions = { + anthropic: { disableBetaFeatures: true, cacheTtl: "1h" as const }, + google: { cache: false }, + }; + await view.updateConfig({ + agentAiDefaults: {}, + defaultModel: "openai:different-default", + userPreferences: { ai: { providerOptions } }, + }); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Private request" } }); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + expect(view.calls.find((call) => call.path === "workspace.sendMessage")?.input).toMatchObject({ + message: "Private request", + options: { model: "anthropic:allowed", providerOptions }, + }); + const answer = deferred(); + view.setAnswer(() => answer.promise); + await submitAnswer(view); + const changedOptions = { + ...providerOptions, + anthropic: { disableBetaFeatures: false }, + }; + await view.updateConfig({ + agentAiDefaults: {}, + userPreferences: { ai: { providerOptions: changedOptions } }, + }); + await act(async () => answer.resolve({ success: true })); + expect(view.calls.find((call) => call.path === "workspace.resumeStream")?.input).toMatchObject({ + options: { model: "anthropic:allowed", providerOptions: changedOptions }, + }); +}); + +test("live route and provider changes re-evaluate policy without replacing the selected model", async () => { + const view = fixture(); + await view.select("alpha"); + fireEvent.click(view.getByRole("button", { name: "Choose model" })); + fireEvent.click(view.getByRole("radio", { name: "anthropic:allowed" })); + const providers = { + anthropic: { isConfigured: true, isEnabled: true, apiKeySet: true }, + coder: { isConfigured: true, isEnabled: true, apiKeySet: false, models: ["anthropic/allowed"] }, + }; + await view.updateProviders(providers); + const config = { agentAiDefaults: {}, routePriority: ["coder", "direct"] }; + await view.updateConfig(config); + await view.updatePolicy({ + source: "env", + status: { state: "enforced" }, + policy: { + policyFormatVersion: "0.1", + providerAccess: [{ id: "coder" }], + mcp: { allowUserDefined: { stdio: true, remote: true } }, + runtimes: null, + }, + }); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Same model" } }); + const send = () => view.getByRole("button", { name: "Send message" }); + expect(send().getAttribute("aria-disabled")).not.toBe("true"); + await view.updateConfig({ ...config, routeOverrides: { "anthropic:allowed": "direct" } }); + expect(send().getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(send()); + expect(callCount(view, "sendMessage")).toBe(0); + await view.updateConfig(config); + expect(send().getAttribute("aria-disabled")).not.toBe("true"); + await view.updateProviders({ ...providers, coder: { ...providers.coder, isEnabled: false } }); + expect(send().getAttribute("aria-disabled")).toBe("true"); + await view.updateProviders(providers); + await act(async () => fireEvent.click(send())); + expect(view.calls.find((call) => call.path === "workspace.sendMessage")?.input).toMatchObject({ + options: { model: "anthropic:allowed" }, + }); + expect(view.calls.filter((call) => call.path === "agents.list")).toHaveLength(1); +}); + +test("unavailable live settings prevent send and retain resume-only recovery after an answer is saved", async () => { + const view = fixture([question()]); + await view.select("alpha"); + const answer = deferred(); + view.setAnswer(() => answer.promise); + await submitAnswer(view); + const read = deferred(); + view.setConfigRead(() => read.promise); + await view.updateConfig({ agentAiDefaults: {} }); + await view.emit(answered()); + await act(async () => answer.resolve({ success: true })); + expect(callCount(view, "resumeStream")).toBe(0); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Wait for settings" } }); + fireEvent.click(view.getByRole("button", { name: "Send message" })); + expect(callCount(view, "sendMessage")).toBe(0); + const providerOptions = { anthropic: { disableBetaFeatures: true } }; + await act(async () => + read.resolve({ agentAiDefaults: {}, userPreferences: { ai: { providerOptions } } }) + ); + await act(async () => fireEvent.click(await view.findByRole("button", { name: "Resume agent" }))); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(view.calls.find((call) => call.path === "workspace.resumeStream")?.input).toMatchObject({ + options: { providerOptions }, + }); +}); diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts index 2555030010d..0bdf96e35f3 100644 --- a/packages/mobile/src/useConversation.test.ts +++ b/packages/mobile/src/useConversation.test.ts @@ -5,6 +5,7 @@ import { createORPCClient } from "@orpc/client"; import type { MobileClient } from "./api"; import type { WorkspaceChatMessage } from "./transcript"; import { useConversation } from "./useConversation"; +import type { SettingsData } from "./settings"; afterEach(cleanup); @@ -21,7 +22,13 @@ function message(sequence: number, text = `message ${sequence}`): WorkspaceChatM type Policy = Awaited>; const disabledPolicy: Policy = { source: "none", status: { state: "disabled" }, policy: null }; -function fixture(getPolicy: () => Promise = async () => disabledPolicy) { +function fixture( + getPolicy: () => Promise = async () => disabledPolicy, + reads: { + config?: () => Promise; + providers?: () => Promise; + } = {} +) { type Page = Awaited>; let complete!: (page: Page) => void; const page = new Promise((resolve) => { @@ -34,6 +41,34 @@ function fixture(getPolicy: () => Promise = async () => disabledPolicy) events: ReadableStreamDefaultController; fail: (error: Error) => void; }> = []; + const configSubscriptions: typeof policySubscriptions = []; + const providerSubscriptions: typeof policySubscriptions = []; + const settingsRequests: Array<{ path: string; signal: AbortSignal }> = []; + const settingsOrder: string[] = []; + function notifications( + subscriptions: typeof policySubscriptions, + signal: AbortSignal, + source: string + ) { + const events = new ReadableStream({ + start(controller) { + const close = () => controller.close(); + subscriptions.push({ + signal, + events: controller, + fail(error) { + signal.removeEventListener("abort", close); + controller.error(error); + }, + }); + signal.addEventListener("abort", close, { once: true }); + }, + }); + return (async function* () { + settingsOrder.push(`${source}.listen`); + yield* events.values(); + })(); + } const requests: Array<{ input: unknown; signal?: AbortSignal }> = []; const client = createORPCClient({ call: async (path, input, options) => { @@ -56,10 +91,20 @@ function fixture(getPolicy: () => Promise = async () => disabledPolicy) options.signal?.addEventListener("abort", close, { once: true }); }, }).values(); + case "config.onConfigChanged": + settingsOrder.push("config.subscribe"); + return notifications(configSubscriptions, options.signal!, "config"); + case "providers.onConfigChanged": + settingsOrder.push("providers.subscribe"); + return notifications(providerSubscriptions, options.signal!, "providers"); case "config.getConfig": - return { agentAiDefaults: {} }; + settingsOrder.push("config.read"); + settingsRequests.push({ path: "config", signal: options.signal! }); + return reads.config ? reads.config() : { agentAiDefaults: {} }; case "providers.getConfig": - return {}; + settingsOrder.push("providers.read"); + settingsRequests.push({ path: "providers", signal: options.signal! }); + return reads.providers ? reads.providers() : {}; case "agents.list": return []; case "workspace.onChat": @@ -79,8 +124,8 @@ function fixture(getPolicy: () => Promise = async () => disabledPolicy) }); const lifetime = new AbortController(); const view = renderHook( - ({ workspaceId }) => useConversation(client, workspaceId, lifetime.signal), - { initialProps: { workspaceId: "workspace" } } + ({ workspaceId, signal }) => useConversation(client, workspaceId, signal), + { initialProps: { workspaceId: "workspace", signal: lifetime.signal } } ); return { ...view, @@ -88,6 +133,10 @@ function fixture(getPolicy: () => Promise = async () => disabledPolicy) requests, policyRequests, policySubscriptions, + configSubscriptions, + providerSubscriptions, + settingsRequests, + settingsOrder, lifetime, async ready() { await waitFor(() => expect(eventController).toBeDefined()); @@ -225,7 +274,7 @@ test("switching workspace cancels old policy scope and ignores its late snapshot }); }); await view.ready(); - view.rerender({ workspaceId: "other" }); + view.rerender({ workspaceId: "other", signal: view.lifetime.signal }); await waitFor(() => expect(view.result.current.settings?.policy).toEqual(blocked)); expect(view.policySubscriptions).toHaveLength(2); expect(view.policySubscriptions[0].signal.aborted).toBe(true); @@ -234,3 +283,127 @@ test("switching workspace cancels old policy scope and ignores its late snapshot expect(view.result.current.settings?.policy).toEqual(blocked); expect(view.policySubscriptions[1].signal.aborted).toBe(false); }); + +test("settings subscriptions precede reads and refresh privacy, routes and provider availability", async () => { + let config: SettingsData["config"] = { agentAiDefaults: {} }; + let providers: SettingsData["providers"] = {}; + const view = fixture(undefined, { + config: async () => config, + providers: async () => providers, + }); + await view.ready(); + expect(view.configSubscriptions).toHaveLength(1); + expect(view.providerSubscriptions).toHaveLength(1); + expect(view.settingsOrder.slice(0, 4)).toEqual([ + "config.subscribe", + "providers.subscribe", + "config.listen", + "providers.listen", + ]); + config = { + ...config, + routePriority: ["coder"], + routeOverrides: { "openai:gpt-4o": "direct" }, + userPreferences: { + ai: { + providerOptions: { anthropic: { disableBetaFeatures: true }, google: { cache: false } }, + }, + }, + }; + await act(async () => view.configSubscriptions[0].events.enqueue()); + await waitFor(() => expect(view.result.current.settings?.config).toEqual(config)); + providers = { + anthropic: { isEnabled: false, isConfigured: true, apiKeySet: true }, + openai: { isEnabled: true, isConfigured: true, apiKeySet: true, store: false }, + }; + await act(async () => view.providerSubscriptions[0].events.enqueue()); + await waitFor(() => expect(view.result.current.settings?.providers).toEqual(providers)); + expect(view.result.current.settings?.config).toEqual(config); + view.unmount(); + expect(view.configSubscriptions[0].signal.aborted).toBe(true); + expect(view.providerSubscriptions[0].signal.aborted).toBe(true); +}); + +test("a newer config event cancels a stale initial read rather than exposing old privacy settings", async () => { + let resolve!: (config: SettingsData["config"]) => void; + let first = true; + const latest: SettingsData["config"] = { + agentAiDefaults: {}, + userPreferences: { ai: { providerOptions: { anthropic: { disableBetaFeatures: true } } } }, + }; + const view = fixture(undefined, { + config: () => { + if (!first) return Promise.resolve(latest); + first = false; + return new Promise((done) => { + resolve = done; + }); + }, + }); + await waitFor(() => expect(view.settingsRequests.length).toBeGreaterThan(0)); + expect(view.configSubscriptions).toHaveLength(1); + expect(view.providerSubscriptions).toHaveLength(1); + expect(view.result.current.settings).toBeNull(); + await act(async () => view.configSubscriptions[0].events.enqueue()); + await waitFor(() => expect(view.result.current.settings?.config).toEqual(latest)); + expect(view.settingsRequests[0].signal.aborted).toBe(true); + await act(async () => resolve({ agentAiDefaults: {} })); + expect(view.result.current.settings?.config).toEqual(latest); +}); + +test.each(["config", "providers"] as const)( + "a failed %s refresh blocks settings until a later notification recovers", + async (source) => { + let failed = false; + const view = fixture(undefined, { + config: async () => { + if (source === "config" && failed) throw new Error("unavailable"); + return { agentAiDefaults: {} }; + }, + providers: async () => { + if (source === "providers" && failed) throw new Error("unavailable"); + return {}; + }, + }); + await view.ready(); + failed = true; + const subscription = + source === "config" ? view.configSubscriptions[0] : view.providerSubscriptions[0]; + await act(async () => subscription.events.enqueue()); + await waitFor(() => expect(view.result.current.error).not.toBeNull()); + expect(view.result.current.settings).toBeNull(); + failed = false; + await act(async () => subscription.events.enqueue()); + await waitFor(() => expect(view.result.current.settings).not.toBeNull()); + expect(view.result.current.error).toBeNull(); + await act(async () => subscription.fail(new Error("disconnected"))); + await waitFor(() => expect(view.result.current.settings).toBeNull()); + expect(view.result.current.error).not.toBeNull(); + } +); + +test.each(["workspace", "connection"])( + "replacing the %s cancels old settings subscriptions and ignores pending snapshots", + async (scope) => { + let resolve!: (config: SettingsData["config"]) => void; + let first = true; + const current: SettingsData["config"] = { agentAiDefaults: {}, routePriority: ["coder"] }; + const view = fixture(undefined, { + config: () => { + if (!first) return Promise.resolve(current); + first = false; + return new Promise((done) => { + resolve = done; + }); + }, + }); + await waitFor(() => expect(view.settingsRequests.length).toBeGreaterThan(0)); + const signal = scope === "connection" ? new AbortController().signal : view.lifetime.signal; + view.rerender({ workspaceId: scope === "workspace" ? "other" : "workspace", signal }); + await waitFor(() => expect(view.result.current.settings?.config).toEqual(current)); + expect(view.configSubscriptions[0].signal.aborted).toBe(true); + expect(view.providerSubscriptions[0].signal.aborted).toBe(true); + await act(async () => resolve({ agentAiDefaults: {} })); + expect(view.result.current.settings?.config).toEqual(current); + } +); diff --git a/packages/mobile/src/useConversation.ts b/packages/mobile/src/useConversation.ts index 58a460899c6..a77684dbda2 100644 --- a/packages/mobile/src/useConversation.ts +++ b/packages/mobile/src/useConversation.ts @@ -9,6 +9,7 @@ export function useConversation(client: MobileClient, workspaceId: string, signa const [settings, setSettings] = useState | null>(null); const [policy, setPolicy] = useState(null); const [error, setError] = useState(null); + const [settingsError, setSettingsError] = useState(null); const [owner, setOwner] = useState(() => ({ client, workspaceId, signal })); const [loadingOlder, setLoadingOlder] = useState(false); const [historyError, setHistoryError] = useState(null); @@ -23,6 +24,7 @@ export function useConversation(client: MobileClient, workspaceId: string, signa setSettings(null); setPolicy(null); setError(null); + setSettingsError(null); setOwner({ client, workspaceId, signal }); setLoadingOlder(false); setHistoryError(null); @@ -52,14 +54,59 @@ export function useConversation(client: MobileClient, workspaceId: string, signa subscribePolicy().catch(() => { if (!controller.signal.aborted) setPolicy(null); }); - async function subscribe() { - const [config, providers, agents] = await Promise.all([ - client.config.getConfig(undefined, { signal: controller.signal }), - client.providers.getConfig(undefined, { signal: controller.signal }), - client.agents.list({ workspaceId }, { signal: controller.signal }), + const settingsController = linkedAbortController(controller.signal); + let settingsRequest: AbortController | null = null; + async function subscribeSettings() { + // Both subscriptions must be registered before reading privacy/routing settings. + const [configEvents, providerEvents, agents] = await Promise.all([ + client.config.onConfigChanged(undefined, { signal: settingsController.signal }), + client.providers.onConfigChanged(undefined, { signal: settingsController.signal }), + client.agents.list({ workspaceId }, { signal: settingsController.signal }), ]); + if (settingsController.signal.aborted) return; + function refresh() { + settingsRequest?.abort(); + const request = linkedAbortController(settingsController.signal); + settingsRequest = request; + // A notification invalidates the old privacy options immediately. Consume + // further notifications while reading, so an older snapshot cannot win. + setSettings(null); + setSettingsError(null); + Promise.all([ + client.config.getConfig(undefined, { signal: request.signal }), + client.providers.getConfig(undefined, { signal: request.signal }), + ]) + .then( + ([config, providers]) => { + if (!request.signal.aborted) setSettings({ config, providers, agents }); + }, + () => { + if (!request.signal.aborted) + setSettingsError("Settings unavailable. Retry to reconnect."); + } + ) + .finally(() => request.abort()); + } + const watching = Promise.all( + [configEvents, providerEvents].map(async (events) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Notifications have no payload. + for await (const _ of events) { + if (settingsController.signal.aborted) return; + refresh(); + } + if (!settingsController.signal.aborted) throw new Error("Settings disconnected"); + }) + ); + refresh(); + await watching; + } + subscribeSettings().catch(() => { if (controller.signal.aborted) return; - setSettings({ config, providers, agents }); + settingsController.abort(); + setSettings(null); + setSettingsError("Settings unavailable. Retry to reconnect."); + }); + async function subscribe() { const events = await client.workspace.onChat( { workspaceId, mode: { type: "full" } }, { signal: controller.signal } @@ -159,7 +206,7 @@ export function useConversation(client: MobileClient, workspaceId: string, signa return { transcript: owned ? transcript : createTranscriptState(), settings: owned && settings ? { ...settings, policy } : null, - error: owned ? error : null, + error: owned ? (error ?? settingsError) : null, loadingOlder, historyError, loadOlder, From c675587b3298efd592d96c67eaf81684956010e0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 16:21:57 +0000 Subject: [PATCH 32/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20keep=20live?= =?UTF-8?q?=20interruption=20independent=20of=20settings=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate settings failures from transport failures so config refreshes block new AI work without hiding Interrupt for an already-running agent. Retain unavailable-settings answer recovery and existing no-op resume handling. Validate with mobile-check (94 pass, one real-server skip), web/iOS exports, and pending/failed-refresh interruption regression. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$28.71`_ --- .../mobile/src/screens/ConversationScreen.tsx | 23 +++++---- .../mobile/src/screens/session.behavior.tsx | 48 +++++++++++++++++++ packages/mobile/src/useConversation.test.ts | 8 ++-- packages/mobile/src/useConversation.ts | 3 +- 4 files changed, 66 insertions(+), 16 deletions(-) diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 2ba822b678d..5bfa89cef75 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -52,11 +52,8 @@ export function ConversationScreen(props: { onChanges: () => void; onSettings: () => void; }) { - const { transcript, settings, error, loadOlder, loadingOlder, historyError } = useConversation( - props.client, - props.workspace.id, - props.signal - ); + const { transcript, settings, error, settingsError, loadOlder, loadingOlder, historyError } = + useConversation(props.client, props.workspace.id, props.signal); const draft = props.draft; const setDraft = props.onDraftChange; const [inputFocused, setInputFocused] = useState(false); @@ -100,11 +97,12 @@ export function ConversationScreen(props: { settings?.providers, transcript.streamingMessageId ); - const ready = - props.connected && !props.signal.aborted && transcript.caughtUp && !error && settings !== null; + // Settings gate new AI work, not the ability to interrupt an existing live stream. + const ready = props.connected && !props.signal.aborted && transcript.caughtUp && !error; + const loadError = error ?? settingsError; const policyBlockReason = settings && options ? getPolicyBlockReason(settings, options.model) : null; - const canAct = ready && !policyBlockReason; + const canAct = ready && settings !== null && !settingsError && !policyBlockReason; const latestSettings = useRef({ options, policyBlockReason }); useEffect(() => { latestSettings.current = { options, policyBlockReason }; @@ -238,7 +236,8 @@ export function ConversationScreen(props: { async function answer(toolCallId: string, answers: Record) { if (!ready) throw new Error("Reconnect before answering."); - if (policyBlockReason) throw new Error(policyBlockReason); + if (!canAct) + throw new Error(policyBlockReason ?? settingsError ?? "Wait for settings before answering."); if (pending.current) throw new Error("Another action is in progress."); if ( !answerMessage || @@ -350,9 +349,9 @@ export function ConversationScreen(props: { /> )} ListEmptyComponent={ - !ready && !error ? ( + !ready && !loadError ? ( - ) : error ? null : ( + ) : loadError ? null : ( What’s on your mind? @@ -363,7 +362,7 @@ export function ConversationScreen(props: { } ListFooterComponent={ - {error && {error}} + {loadError && {loadError}} {transcript.error && {transcript.error}} {canResume && ( + )} + ) : ( setComposerHeight(event.nativeEvent.layout.height)} > - setDraft((current) => ({ ...current, text }))} - multiline - onKeyPress={Platform.OS === "web" ? handleComposerKeyPress : undefined} - onFocus={() => setInputFocused(true)} - onBlur={() => setInputFocused(false)} - onContentSizeChange={ - Platform.OS === "web" - ? undefined - : (event) => - setInputHeight( - Math.max(44, Math.min(132, event.nativeEvent.contentSize.height)) - ) - } - style={[ - styles.input, - expanded && styles.expandedInput, - Platform.OS === "web" - ? webInputSizing - : { height: expanded ? Math.max(72, inputHeight) : 44 }, - ]} - selectionColor={colors.accent} - /> + {modelBlockReason && ( + + {modelBlockReason} + + )} + {actionError && ( + { + setActionError(null); + return props.onReconnect(); + }} + > + {actionError} + + )} + + {/* Keep the input bottommost. Pointer presses retain browser focus until click opens the picker, avoiding blur-driven movement. */} + + + event.preventDefault() : undefined + } + onPress={() => setShowSettings("agent")} + style={({ pressed }) => [ + styles.modelButton, + { maxWidth: "45%" }, + pressed && { opacity: 0.6 }, + ]} + > + {options?.agentId === "plan" ? ( + + ) : ( + + )} + + {settings?.agents.find((agent) => agent.id === options?.agentId)?.name ?? + options?.agentId ?? + "Mode"} + + + + event.preventDefault() : undefined + } + onPress={() => setShowSettings("model")} + style={({ pressed }) => [styles.modelButton, pressed && { opacity: 0.6 }]} + > + + {options?.model ? modelName(options.model) : "Model"} + + {options && ( + + {(options.thinkingLevel ?? THINKING_LEVEL_OFF).toUpperCase()} + + )} + + + + + - setDraft((current) => ({ ...current, text }))} + multiline + onKeyPress={Platform.OS === "web" ? handleComposerKeyPress : undefined} + onFocus={() => setInputFocused(true)} + onBlur={() => setInputFocused(false)} + onContentSizeChange={ + Platform.OS === "web" + ? undefined + : (event) => + setInputHeight( + Math.max(44, Math.min(132, event.nativeEvent.contentSize.height)) + ) + } + style={[ + styles.input, + expanded && styles.expandedInput, + Platform.OS === "web" + ? webInputSizing + : { height: expanded ? Math.max(72, inputHeight) : 44 }, + ]} + selectionColor={colors.accent} /> + + + - - {showSettings && settings && options && ( - setShowSettings(null)} - onChange={props.onSelectionChange} - /> )} + {showSettings && + settings && + options && + !transcriptOnly && + (!modeLocked || showSettings === "model") && ( + setShowSettings(null)} + onChange={props.onSelectionChange} + /> + )}
); } diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index d9835cd96c6..d82a63ed19f 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -39,8 +39,13 @@ const disabledPolicy: Policy = { source: "none", status: { state: "disabled" }, function fixture( messages: WorkspaceChatMessage[] = [], wide = false, - initialPolicy: Policy | Error = disabledPolicy + initialPolicy: Policy | Error = disabledPolicy, + initialWorkspaces: FrontendWorkspaceMetadata[] = workspaces ) { + let workspaceList = initialWorkspaces; + const metadataEvents: Array< + ReadableStreamDefaultController<{ workspaceId: string; metadata: FrontendWorkspaceMetadata }> + > = []; Object.defineProperty(document.documentElement, "clientWidth", { configurable: true, value: wide ? 1200 : 375, @@ -94,9 +99,9 @@ function fixture( calls.push({ path: name, input, signal: options.signal }); switch (name) { case "workspace.onMetadata": - return events(options.signal); + return events(options.signal, [], (controller) => metadataEvents.push(controller)); case "workspace.list": - return workspaces; + return workspaceList; case "projects.list": return []; case "policy.get": @@ -115,6 +120,7 @@ function fixture( case "agents.list": return [ { id: "exec", name: "Exec", uiSelectable: true }, + { id: "explore", name: "Explore", uiSelectable: false }, { id: "plan", name: "Plan", uiSelectable: true }, ]; case "workspace.onChat": { @@ -198,10 +204,16 @@ function fixture( fireEvent.click(await view.findByRole("button", { name: id })); await waitFor(() => expect( - view.getByRole("button", { name: "Choose mode" }).getAttribute("aria-disabled") + view.getByRole("button", { name: "Choose model" }).getAttribute("aria-disabled") ).not.toBe("true") ); }, + async updateWorkspace(metadata: FrontendWorkspaceMetadata) { + workspaceList = workspaceList.map((workspace) => + workspace.id === metadata.id ? metadata : workspace + ); + await act(async () => metadataEvents.at(-1)!.enqueue({ workspaceId: metadata.id, metadata })); + }, setConfigRead(read: typeof configRead) { configRead = read; }, @@ -223,6 +235,142 @@ function fixture( }; } +test("searched delegated workspaces keep legacy/current identity locked while allowing model changes", async () => { + for (const [identity, expected, label] of [ + [{ agentType: "explore" }, "explore", "Explore"], + [{ agentId: "custom-worker" }, "custom-worker", "custom-worker"], + [{ agentType: "explore", agentId: "exec" }, "explore", "Explore"], + ] as const) { + const child = { ...workspaces[0], ...identity, parentWorkspaceId: "beta" }; + const view = fixture([], false, disabledPolicy, [child, workspaces[1]]); + fireEvent.change(await view.findByLabelText("Search workspaces"), { + target: { value: "alpha" }, + }); + await view.select("alpha"); + const mode = view.getByRole("button", { name: "Choose mode" }); + expect(mode.getAttribute("aria-disabled")).toBe("true"); + expect(mode.textContent).toContain(label); + fireEvent.click(mode); + expect(view.queryByRole("radio", { name: "Plan" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Choose model" })); + fireEvent.click(view.getByRole("radio", { name: "anthropic:allowed" })); + fireEvent.change(view.getByLabelText("Message"), { + target: { value: "Continue delegated work" }, + }); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + expect(view.calls.find((call) => call.path === "workspace.sendMessage")?.input).toMatchObject({ + options: { agentId: expected, model: "anthropic:allowed" }, + }); + view.unmount(); + } +}); + +test("delegated request options override an earlier remembered root mode for send and recovery", async () => { + const view = fixture([answeredPartial()]); + await view.select("alpha"); + fireEvent.click(view.getByRole("button", { name: "Choose mode" })); + fireEvent.click(view.getByRole("radio", { name: "Plan" })); + await view.updateWorkspace({ ...workspaces[0], parentWorkspaceId: "beta", agentType: "explore" }); + expect(view.getByRole("button", { name: "Choose mode" }).getAttribute("aria-disabled")).toBe( + "true" + ); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Keep identity" } }); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Resume agent" }))); + for (const path of ["workspace.sendMessage", "workspace.resumeStream"]) { + expect(view.calls.find((call) => call.path === path)?.input).toMatchObject({ + options: { agentId: "explore" }, + }); + } +}); + +test("transcript-only workspaces keep history and drafts but expose no send or recovery controls", async () => { + const history: WorkspaceChatMessage = { + type: "message", + id: "history", + role: "user", + parts: [{ type: "text", text: "Retained history" }], + metadata: { historySequence: 0 }, + }; + const view = fixture([history, answeredPartial()]); + await view.select("alpha"); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Retained draft" } }); + const oldInput = view.getByLabelText("Message"); + act(() => oldInput.focus()); + const oldResume = view.getByRole("button", { name: "Resume agent" }); + await view.updateWorkspace({ ...workspaces[0], transcriptOnly: true }); + expect(view.getByText("Retained history")).toBeDefined(); + expect(view.getByRole("note").textContent).toContain("read-only"); + expect(view.queryByLabelText("Message")).toBeNull(); + expect(view.queryByRole("button", { name: "Send message" })).toBeNull(); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + fireEvent.click(oldResume); + fireEvent.keyDown(oldInput, { key: "Enter", ctrlKey: true }); + expect(callCount(view, "sendMessage")).toBe(0); + expect(callCount(view, "resumeStream")).toBe(0); + fireEvent.click(view.getByRole("button", { name: "Back to workspaces" })); + fireEvent.click(await view.findByRole("button", { name: "alpha" })); + expect(await view.findByRole("note")).toBeDefined(); + await view.updateWorkspace(workspaces[0]); + expect(await view.findByLabelText("Message")).toHaveProperty("value", "Retained draft"); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + expect(callCount(view, "sendMessage")).toBe(1); +}); + +test("transcript-only metadata arriving during an answer prevents its resume continuation", async () => { + const view = fixture([question()]); + await view.select("alpha"); + const answer = deferred(); + view.setAnswer(() => answer.promise); + await submitAnswer(view); + await view.emit(answered()); + await view.updateWorkspace({ ...workspaces[0], transcriptOnly: true }); + await act(async () => answer.resolve({ success: true })); + expect(callCount(view, "resumeStream")).toBe(0); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); +}); + +test("transcript-only pending questions are read-only while live Stop remains available", async () => { + for (const live of [false, true]) { + const messages: WorkspaceChatMessage[] = live + ? [ + { + type: "stream-start", + workspaceId: "alpha", + messageId: "question", + model, + historySequence: 1, + startTime: 1, + }, + question(), + ] + : [question()]; + const view = fixture(messages); + await view.select("alpha"); + fireEvent.click( + within(view.getByRole("radiogroup", { name: "Answer question?" })).getByRole("radio", { + name: "main", + }) + ); + await view.updateWorkspace({ ...workspaces[0], transcriptOnly: true }); + expect(view.getByRole("button", { name: "Send answers" }).getAttribute("aria-disabled")).toBe( + "true" + ); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(callCount(view, "answerAskUserQuestion")).toBe(0); + if (live) { + view.setInterrupt(async () => ({ success: false, error: "Stop failed" })); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Interrupt agent" }))); + expect(callCount(view, "interruptStream")).toBe(1); + expect(view.getByRole("alert").textContent).toContain("Stop failed"); + view.setInterrupt(async () => ({ success: true })); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Interrupt agent" }))); + expect(callCount(view, "interruptStream")).toBe(2); + } + view.unmount(); + } +}); + test("failed credential clearing leaves the session usable and reconnectable before retrying disconnect", async () => { const view = fixture(); await view.select("alpha"); diff --git a/packages/mobile/src/settings.test.ts b/packages/mobile/src/settings.test.ts index 38c2569b932..c41c9c7b6b0 100644 --- a/packages/mobile/src/settings.test.ts +++ b/packages/mobile/src/settings.test.ts @@ -361,6 +361,32 @@ describe("mobile model settings", () => { providerOptions: undefined, }); }); + test("delegated identities ignore requested and remembered agent overrides, including legacy metadata", () => { + const settings = data(); + for (const [identity, expected] of [ + [{ agentType: " Explore " }, "explore"], + [{ agentId: "custom-worker" }, "custom-worker"], + [{ agentType: "explore", agentId: "exec" }, "explore"], + ] as const) { + const workspace = { + ...identity, + parentWorkspaceId: "parent", + aiSettings: { model: "saved:model", thinkingLevel: "off" as const }, + }; + expect(resolveSettings(workspace, settings, "plan").agentId).toBe(expected); + expect(resolveSettings(workspace, settings, "plan").model).toBe("saved:model"); + const selected = { agentId: "plan", model: "selected:model", thinkingLevel: "high" as const }; + expect(resolveSettings(workspace, settings, "plan", selected)).toMatchObject({ + ...selected, + agentId: expected, + }); + expect(selected.agentId).toBe("plan"); + } + expect( + resolveSettings({}, settings, "plan", { agentId: "plan", model: "root:model" }).agentId + ).toBe("plan"); + }); + test("unset effort is Off, including an explicit model with Default effort", () => { const config = data(); expect(resolveSettings({}, config, "exec").thinkingLevel).toBe("off"); diff --git a/packages/mobile/src/settings.ts b/packages/mobile/src/settings.ts index 4c71df313e2..066d01b7e97 100644 --- a/packages/mobile/src/settings.ts +++ b/packages/mobile/src/settings.ts @@ -4,6 +4,7 @@ import { isCodexOauthRequiredModel, } from "../../../src/common/constants/codexOAuth"; import { isModelAvailable, resolveRoute } from "../../../src/common/routing"; +import { resolvePersistedAgentId } from "../../../src/common/utils/agentIds"; import { collectDeclaredAncestorLayers } from "../../../src/common/utils/ai/agentAncestorLayers"; import { resolveAgentAiSettings } from "../../../src/common/utils/ai/resolveAgentAiSettings"; import { targetWorkspaceBucketToLayer } from "../../../src/common/types/agentAiSettings"; @@ -41,14 +42,21 @@ export type ChatSettings = Pick< export const thinkingLevels: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh", "max"]; export function resolveSettings( - workspace: Pick, + workspace: Pick< + FrontendWorkspaceMetadata, + "aiSettingsByAgent" | "agentId" | "agentType" | "parentWorkspaceId" | "aiSettings" + >, data: SettingsData, - agentId: string, + requestedAgentId: string, selection?: ChatSettings | null ): ChatSettings { + const persistedAgentId = resolvePersistedAgentId(workspace); + // A delegated workspace's creation identity is not a user-selectable mode. + // Enforce this in request options too, not only in the picker. + const agentId = workspace.parentWorkspaceId != null ? persistedAgentId : requestedAgentId; const workspaceDefaults = workspace.aiSettingsByAgent?.[agentId] ?? - (workspace.agentId === agentId ? workspace.aiSettings : undefined); + (persistedAgentId === agentId ? workspace.aiSettings : undefined); const descriptors = new Map( data.agents.map((agent) => [ agent.id, From d95c80ffb1ad742095903a0c95874fed68e8281d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 23:23:25 +0000 Subject: [PATCH 55/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20initialize?= =?UTF-8?q?=20structured=20question=20prefills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve canonical ask_user_question answers when rendering the native form. Seed draft selections and Other text once per tool-call identity so streaming rerenders retain user edits and a different call starts with fresh draft/submission state. Extract desktop prefill tokenization into one React-free semantic parser with thin platform-local draft adapters. Preserve single/multi-select matching, custom text, ordering, whitespace handling, and existing schema rejection of malformed payloads. Validation: reproduced five prefill/identity failures before the fix; all ten mobile prefill regressions and three shared parser tests pass. Full mobile-check passes (131 tests, 1 existing live-server skip), and root static-check including both typechecks passes. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$78.18`_ --- packages/mobile/src/components/Message.tsx | 46 ++++-- .../mobile/src/screens/forms.behavior.tsx | 148 ++++++++++++++++++ .../Tools/AskUserQuestionToolCall.tsx | 42 +---- .../tools/parseAskUserQuestionAnswer.test.ts | 55 +++++++ .../utils/tools/parseAskUserQuestionAnswer.ts | 28 ++++ 5 files changed, 271 insertions(+), 48 deletions(-) create mode 100644 src/common/utils/tools/parseAskUserQuestionAnswer.test.ts create mode 100644 src/common/utils/tools/parseAskUserQuestionAnswer.ts diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index 3a9fa6c6f92..a6a6a7424f7 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -2,8 +2,12 @@ import { useState } from "react"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { Brain, Check, ChevronDown, ChevronRight, File, Pause } from "lucide-react-native"; import type { MuxMessage, MuxToolPart } from "../../../../src/common/types/message"; -import type { AskUserQuestionQuestion } from "../../../../src/common/types/tools"; +import type { + AskUserQuestionQuestion, + AskUserQuestionToolArgs, +} from "../../../../src/common/types/tools"; import { AskUserQuestionToolArgsSchema } from "../../../../src/common/utils/tools/toolDefinitions"; +import { parseAskUserQuestionAnswer } from "../../../../src/common/utils/tools/parseAskUserQuestionAnswer"; import { Button, Field, Notice, Sheet } from "./Controls"; import { Markdown } from "./Markdown"; import { ToolIcon } from "./ToolIcon"; @@ -166,10 +170,10 @@ function Tool(props: { onAnswer: (toolCallId: string, answers: Record) => Promise; }) { const [inspecting, setInspecting] = useState(false); - const questions = + const questionInput = props.part.toolName === "ask_user_question" && props.part.state === "input-available" - ? (AskUserQuestionToolArgsSchema.safeParse(props.part.input).data?.questions ?? []) - : []; + ? AskUserQuestionToolArgsSchema.safeParse(props.part.input).data + : undefined; const name = props.part.toolName .replaceAll("_", " ") .replace(/^./, (letter) => letter.toUpperCase()); @@ -213,9 +217,9 @@ function Tool(props: { )} )} - {questions.length > 0 && ( + {questionInput && ( props.onAnswer(props.part.toolCallId, answers)} /> @@ -229,12 +233,30 @@ interface QuestionDraft { otherText: string; } -function QuestionForm(props: { - questions: AskUserQuestionQuestion[]; - disabled: boolean; - onSubmit: (answers: Record) => Promise; -}) { - const [drafts, setDrafts] = useState(() => new Map()); +function parsePrefilledAnswer(question: AskUserQuestionQuestion, answer: string): QuestionDraft { + const { optionLabels, customText } = parseAskUserQuestionAnswer(question, answer); + return { + selected: customText ? [...optionLabels, null] : optionLabels, + otherText: customText, + }; +} + +function QuestionForm( + props: Pick & { + disabled: boolean; + onSubmit: (answers: Record) => Promise; + } +) { + // Tool is keyed by toolCallId: seed once so streaming rerenders preserve user edits. + const [drafts, setDrafts] = useState(() => { + const prefilled = new Map(Object.entries(props.answers ?? {})); + return new Map( + props.questions.map((question) => [ + question.question, + parsePrefilledAnswer(question, prefilled.get(question.question) ?? ""), + ]) + ); + }); // Match desktop answer serialization: selection order, comma-separated labels, // and trimmed Other text. Null keeps the implicit choice distinct from tool labels. const answers = Object.fromEntries( diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 90ea4ba2975..27d0e1fd67a 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -992,6 +992,154 @@ test.each(["Which features?", "__proto__"])( } ); +function prefilledQuestionPart( + answers: unknown, + multiSelect = false, + toolCallId = "prefilled" +): MuxToolPart { + return { + type: "dynamic-tool", + toolCallId, + toolName: "ask_user_question", + state: "input-available", + input: { + questions: [ + { + question: "Which branch?", + header: "Branch", + options: [ + { label: "main", description: "Stable branch" }, + { label: "next", description: "Upcoming release" }, + ], + multiSelect, + }, + ], + answers, + }, + }; +} + +test.each([ + { multi: false, answer: "main", choices: ["main"], other: null }, + { multi: false, answer: "feature, urgent", choices: ["Other"], other: "feature, urgent" }, + { multi: true, answer: "next, main", choices: ["next", "main"], other: null }, + { + multi: true, + answer: "next, main, custom one, custom two", + choices: ["next", "main", "Other"], + other: "custom one, custom two", + }, +])( + "prefilled question displays and submits without edits: %j", + async ({ multi, answer, choices, other }) => { + const submitted: Array> = []; + const part = prefilledQuestionPart( + { "Which branch?": ` ${answer} `, unrelated: "Ignore this" }, + multi + ); + const view = render( + { + submitted.push(value); + }} + /> + ); + for (const label of ["main", "next", "Other"]) { + expect( + view.getByRole(multi ? "checkbox" : "radio", { name: label }).getAttribute("aria-checked") + ).toBe(String(choices.some((choice) => choice === label))); + } + if (other !== null) + expect(view.getByDisplayValue(other)).toBe(view.getByLabelText("Other: Which branch?")); + else expect(view.queryByLabelText("Other: Which branch?")).toBeNull(); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + }); + expect(submitted).toEqual([{ "Which branch?": answer }]); + } +); + +test("prefilled drafts preserve user edits across deltas but reset for a different tool call", async () => { + const submitted: Array<{ id: string; value: Record }> = []; + const onAnswer = async (id: string, value: Record) => { + submitted.push({ id, value }); + }; + const view = render( + + ); + fireEvent.click(view.getByRole("radio", { name: "Other" })); + fireEvent.change(view.getByLabelText("Other: Which branch?"), { + target: { value: "My edited branch" }, + }); + view.rerender( + + ); + expect(view.getByDisplayValue("My edited branch")).toBe( + view.getByLabelText("Other: Which branch?") + ); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + }); + expect(submitted).toEqual([{ id: "prefilled", value: { "Which branch?": "My edited branch" } }]); + view.rerender( + + ); + expect(view.queryByLabelText("Other: Which branch?")).toBeNull(); + expect(view.getByRole("radio", { name: "next" }).getAttribute("aria-checked")).toBe("true"); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + }); + expect(submitted[1]).toEqual({ id: "another-call", value: { "Which branch?": "next" } }); +}); + +test.each([undefined, null, {}, { "Which branch?": " " }])( + "missing or blank prefilled answers remain unanswered: %j", + (answers) => { + const view = render( + {}} + /> + ); + expect(view.getByRole("button", { name: "Send answers" }).getAttribute("aria-disabled")).toBe( + "true" + ); + expect(view.getByRole("radio", { name: "Other" }).getAttribute("aria-checked")).toBe("false"); + expect(view.queryByLabelText("Other: Which branch?")).toBeNull(); + } +); + +test("invalid prefilled answer types are rejected by the canonical schema without crashing the message", () => { + const view = render( + {}} + /> + ); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Ask user question: No result" })); + expect(view.getByText(/42/)).toBeDefined(); +}); + test("malformed question payloads stay inspectable without presenting an incomplete answer form", () => { const part: MuxToolPart = { type: "dynamic-tool", diff --git a/src/browser/features/Tools/AskUserQuestionToolCall.tsx b/src/browser/features/Tools/AskUserQuestionToolCall.tsx index d859c2a1203..6f94aad3873 100644 --- a/src/browser/features/Tools/AskUserQuestionToolCall.tsx +++ b/src/browser/features/Tools/AskUserQuestionToolCall.tsx @@ -34,6 +34,7 @@ import type { ToolErrorResult, } from "@/common/types/tools"; import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; +import { parseAskUserQuestionAnswer } from "@/common/utils/tools/parseAskUserQuestionAnswer"; import { getErrorMessage } from "@/common/utils/errors"; import { formatSendMessageError } from "@/common/utils/errors/formatSendError"; @@ -98,42 +99,11 @@ function isToolErrorResult(val: unknown): val is ToolErrorResult { } function parsePrefilledAnswer(question: AskUserQuestionQuestion, answer: string): DraftAnswer { - const trimmed = answer.trim(); - if (trimmed.length === 0) { - return { selected: [], otherText: "" }; - } - - const optionLabels = new Set(question.options.map((o) => o.label)); - - if (!question.multiSelect) { - if (optionLabels.has(trimmed)) { - return { selected: [trimmed], otherText: "" }; - } - - return { selected: [OTHER_VALUE], otherText: trimmed }; - } - - const tokens = trimmed - .split(",") - .map((t) => t.trim()) - .filter((t) => t.length > 0); - - const selected: string[] = []; - const otherParts: string[] = []; - - for (const token of tokens) { - if (optionLabels.has(token)) { - selected.push(token); - } else { - otherParts.push(token); - } - } - - if (otherParts.length > 0) { - selected.push(OTHER_VALUE); - } - - return { selected, otherText: otherParts.join(", ") }; + const { optionLabels, customText } = parseAskUserQuestionAnswer(question, answer); + return { + selected: customText ? [...optionLabels, OTHER_VALUE] : optionLabels, + otherText: customText, + }; } function isQuestionAnswered(_question: AskUserQuestionQuestion, draft: DraftAnswer): boolean { diff --git a/src/common/utils/tools/parseAskUserQuestionAnswer.test.ts b/src/common/utils/tools/parseAskUserQuestionAnswer.test.ts new file mode 100644 index 00000000000..c16c910c387 --- /dev/null +++ b/src/common/utils/tools/parseAskUserQuestionAnswer.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { parseAskUserQuestionAnswer } from "./parseAskUserQuestionAnswer"; + +const question = { + question: "Which branch?", + header: "Branch", + options: [ + { label: "main", description: "Stable branch" }, + { label: "next", description: "Upcoming release" }, + ], + multiSelect: false, +}; + +test("single-select prefills distinguish exact labels from custom text without splitting commas", () => { + expect(parseAskUserQuestionAnswer(question, " main ")).toEqual({ + optionLabels: ["main"], + customText: "", + }); + expect(parseAskUserQuestionAnswer(question, " MAIN, next ")).toEqual({ + optionLabels: [], + customText: "MAIN, next", + }); + expect( + parseAskUserQuestionAnswer( + { + ...question, + options: [...question.options, { label: "main, next", description: "Both branches" }], + }, + "main, next" + ) + ).toEqual({ optionLabels: ["main, next"], customText: "" }); +}); + +test("multi-select keeps desktop ordering and duplicates while combining custom fragments", () => { + expect( + parseAskUserQuestionAnswer( + { ...question, multiSelect: true }, + " next, , custom one, main, custom two, next " + ) + ).toEqual({ + optionLabels: ["next", "main", "next"], + customText: "custom one, custom two", + }); +}); + +test("blank answers and empty multi-select tokens do not create an Other selection", () => { + expect(parseAskUserQuestionAnswer(question, " \n ")).toEqual({ + optionLabels: [], + customText: "", + }); + expect(parseAskUserQuestionAnswer({ ...question, multiSelect: true }, " , , ")).toEqual({ + optionLabels: [], + customText: "", + }); +}); diff --git a/src/common/utils/tools/parseAskUserQuestionAnswer.ts b/src/common/utils/tools/parseAskUserQuestionAnswer.ts new file mode 100644 index 00000000000..7abd5c21b9d --- /dev/null +++ b/src/common/utils/tools/parseAskUserQuestionAnswer.ts @@ -0,0 +1,28 @@ +import type { AskUserQuestionQuestion } from "@/common/types/tools"; + +// Return semantic answer parts; each renderer owns its draft state and Other sentinel. +export function parseAskUserQuestionAnswer( + question: AskUserQuestionQuestion, + answer: string +): { optionLabels: string[]; customText: string } { + const trimmed = answer.trim(); + if (trimmed.length === 0) return { optionLabels: [], customText: "" }; + + const optionLabels = new Set(question.options.map((option) => option.label)); + if (!question.multiSelect) { + return optionLabels.has(trimmed) + ? { optionLabels: [trimmed], customText: "" } + : { optionLabels: [], customText: trimmed }; + } + + const selected: string[] = []; + const otherParts: string[] = []; + for (const token of trimmed + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0)) { + if (optionLabels.has(token)) selected.push(token); + else otherParts.push(token); + } + return { optionLabels: selected, customText: otherParts.join(", ") }; +} From 9bfd4cf803ccbdf109a63364c484661c5a22e98e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 7 Sep 2026 23:28:03 +0000 Subject: [PATCH 56/84] =?UTF-8?q?=F0=9F=A4=96=20tests(mobile):=20cover=20u?= =?UTF-8?q?ntrusted=20question=20prefill=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify inherited constructor and __proto__ prefills remain unanswered, while explicit selections submit own properties without changing the answer object prototype. The existing Map/Object.entries initializer and Object.fromEntries submission need no additional production defenses. Validation: both focused key-safety regressions and the complete mobile-check gate pass (131 tests, 1 existing live-server skip), including mobile typecheck, lint, and formatting. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$87.02`_ --- .../mobile/src/screens/forms.behavior.tsx | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 27d0e1fd67a..e0437e6f8c3 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -995,7 +995,8 @@ test.each(["Which features?", "__proto__"])( function prefilledQuestionPart( answers: unknown, multiSelect = false, - toolCallId = "prefilled" + toolCallId = "prefilled", + questionText = "Which branch?" ): MuxToolPart { return { type: "dynamic-tool", @@ -1005,7 +1006,7 @@ function prefilledQuestionPart( input: { questions: [ { - question: "Which branch?", + question: questionText, header: "Branch", options: [ { label: "main", description: "Stable branch" }, @@ -1127,6 +1128,37 @@ test.each([undefined, null, {}, { "Which branch?": " " }])( } ); +test.each(["constructor", "__proto__"])( + "prefilled answer keys ignore inherited %s values and submit own properties safely", + async (question) => { + const inheritedAnswers: unknown = Object.create(Object.fromEntries([[question, "main"]])); + const submitted: Array> = []; + const view = render( + { + submitted.push(value); + }} + /> + ); + expect(view.getByRole("radio", { name: "main" }).getAttribute("aria-checked")).toBe("false"); + expect(view.getByRole("button", { name: "Send answers" }).getAttribute("aria-disabled")).toBe( + "true" + ); + fireEvent.click(view.getByRole("radio", { name: "next" })); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + }); + expect(submitted).toHaveLength(1); + expect(Object.hasOwn(submitted[0], question)).toBe(true); + expect(submitted[0][question]).toBe("next"); + expect(Object.getPrototypeOf(submitted[0])).toBe(Object.prototype); + } +); + test("invalid prefilled answer types are rejected by the canonical schema without crashing the message", () => { const view = render( Date: Tue, 8 Sep 2026 00:22:41 +0000 Subject: [PATCH 57/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20refresh=20p?= =?UTF-8?q?roject=20catalogs=20on=20config=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribe to config notifications and workspace metadata before initial snapshots. Refresh the bulk project catalog without reconnecting or relisting workspaces, cancel superseded reads, and preserve independently arriving workspace metadata. Keep scratch projects excluded and abort the subscription generation on failures so the existing retry action reconnects cleanly. Add focused behavioral tests for live catalog changes, snapshot ordering, stale responses/errors, cancellation and retry generations, stream failures, and initial-read recovery. Validation with Bun 1.3.5: ten focused tests (124 assertions), mobile-check (142 passed, one optional server skip), and full static-check passed. No API, dependency, or shared fixture changes. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$149.54`_ --- packages/mobile/src/useProjects.test.ts | 291 ++++++++++++++++++++++++ packages/mobile/src/useProjects.ts | 101 +++++--- 2 files changed, 365 insertions(+), 27 deletions(-) create mode 100644 packages/mobile/src/useProjects.test.ts diff --git a/packages/mobile/src/useProjects.test.ts b/packages/mobile/src/useProjects.test.ts new file mode 100644 index 00000000000..40bd9b93dcc --- /dev/null +++ b/packages/mobile/src/useProjects.test.ts @@ -0,0 +1,291 @@ +import "./testDom"; +import { afterEach, expect, test } from "bun:test"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createORPCClient } from "@orpc/client"; +import { SCRATCH_PROJECT_CONFIG_KEY } from "../../../src/common/constants/scratch"; +import type { FrontendWorkspaceMetadata } from "../../../src/common/types/workspace"; +import type { MobileClient } from "./api"; +import { useProjects, type Projects } from "./useProjects"; + +afterEach(cleanup); + +type MetadataEvent = + Awaited> extends AsyncIterable + ? Event + : never; +type Subscription = { + signal: AbortSignal; + emit: (event: T) => void; + end: () => void; + fail: () => void; +}; +function request(signal: AbortSignal) { + return { ...Promise.withResolvers(), signal }; +} +function catalog(name: string): Projects { + return [["/repo", { displayName: name, workspaces: [] }]]; +} +const workspace: FrontendWorkspaceMetadata = { + id: "w", + name: "old", + projectName: "repo", + projectPath: "/repo", + namedWorkspacePath: "/repo/old", + runtimeConfig: { type: "local" }, +}; +function server() { + const order: string[] = []; + const projectReads: Array>> = []; + const workspaceReads: Array>> = []; + const config: Array> = []; + const metadata: Array> = []; + function subscribe(subscriptions: Array>, signal: AbortSignal) { + return new ReadableStream({ + start(controller) { + let closed = false; + const end = () => { + if (!closed) { + closed = true; + controller.close(); + } + }; + subscriptions.push({ + signal, + emit: (event) => controller.enqueue(event), + end, + fail: () => { + closed = true; + controller.error(new Error("subscription failed")); + }, + }); + signal.addEventListener("abort", end, { once: true }); + }, + }).values(); + } + const client = createORPCClient({ + call: async (path, _input, options) => { + if (!options.signal) throw new Error("Expected scoped request"); + const method = path.join("."); + order.push(method); + switch (method) { + case "workspace.onMetadata": + return subscribe(metadata, options.signal); + case "config.onConfigChanged": + return subscribe(config, options.signal); + case "projects.list": { + const next = request(options.signal); + projectReads.push(next); + return next.promise; + } + case "workspace.list": { + const next = request(options.signal); + workspaceReads.push(next); + return next.promise; + } + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + }); + return { client, order, projectReads, workspaceReads, config, metadata }; +} +function mount(source = server()) { + const lifetime = new AbortController(); + const view = renderHook(({ client, signal }) => useProjects(client, signal), { + initialProps: { client: source.client, signal: lifetime.signal }, + }); + return { + ...view, + source, + lifetime, + async ready() { + await waitFor(() => expect(source.projectReads).toHaveLength(1)); + await act(async () => { + source.projectReads[0].resolve(catalog("initial")); + source.workspaceReads[0].resolve([workspace]); + }); + await waitFor(() => expect(view.result.current.loading).toBe(false)); + }, + }; +} + +test("subscribes before snapshots and refreshes the catalog without reconnect or workspace relisting", async () => { + const view = mount(); + await view.ready(); + const { source } = view; + expect(source.order.slice(0, 2)).toEqual(["workspace.onMetadata", "config.onConfigChanged"]); + const snapshots: Projects[] = [ + [ + ...catalog("renamed"), + ["/added", { displayName: "Added", workspaces: [], defaultRuntime: "ssh", trusted: true }], + [SCRATCH_PROJECT_CONFIG_KEY, { workspaces: [] }], + ], + [ + [ + "/added", + { displayName: "Reconfigured", workspaces: [], defaultRuntime: "local", trusted: false }, + ], + ], + [], + ]; + for (const [index, next] of snapshots.entries()) { + await act(async () => source.config[0].emit()); + await waitFor(() => expect(source.projectReads).toHaveLength(index + 2)); + await act(async () => source.projectReads[index + 1].resolve(next)); + expect(view.result.current.projects).toEqual( + next.filter(([path]) => path !== SCRATCH_PROJECT_CONFIG_KEY) + ); + } + expect(source.config).toHaveLength(1); + expect(source.metadata).toHaveLength(1); + expect(source.workspaceReads).toHaveLength(1); + expect(view.result.current.workspaces).toEqual([workspace]); +}); + +test("new notifications cancel stale reads while workspace events remain live", async () => { + const view = mount(); + const { source } = view; + await waitFor(() => expect(source.projectReads).toHaveLength(1)); + await act(async () => source.workspaceReads[0].resolve([workspace])); + expect(source.config).toHaveLength(1); + await act(async () => source.config[0].emit()); + await waitFor(() => expect(source.projectReads).toHaveLength(2)); + expect(source.projectReads[0].signal.aborted).toBe(true); + await act(async () => + source.metadata[0].emit({ workspaceId: "w", metadata: { ...workspace, title: "live update" } }) + ); + expect(view.result.current.workspaces[0].title).toBe("live update"); + await act(async () => source.projectReads[1].resolve(catalog("new"))); + expect(view.result.current.loading).toBe(false); + await act(async () => source.projectReads[0].resolve(catalog("stale"))); + expect(view.result.current.projects).toEqual(catalog("new")); + await act(async () => source.config[0].emit()); + await waitFor(() => expect(source.projectReads).toHaveLength(3)); + await act(async () => source.config[0].emit()); + await waitFor(() => expect(source.projectReads).toHaveLength(4)); + await act(async () => source.projectReads[3].resolve(catalog("latest"))); + await act(async () => source.projectReads[2].reject(new Error("stale error"))); + expect(view.result.current.error).toBeNull(); + expect(view.result.current.projects).toEqual(catalog("latest")); + expect(view.result.current.workspaces[0].title).toBe("live update"); +}); + +test("metadata arriving during the initial workspace read is applied after its snapshot", async () => { + const view = mount(); + const { source } = view; + await waitFor(() => expect(source.projectReads).toHaveLength(1)); + await act(async () => + source.metadata[0].emit({ workspaceId: "w", metadata: { ...workspace, title: "new title" } }) + ); + await act(async () => { + source.projectReads[0].resolve(catalog("project")); + source.workspaceReads[0].resolve([workspace]); + }); + expect(view.result.current.workspaces[0].title).toBe("new title"); + await act(async () => source.metadata[0].emit({ workspaceId: "w", metadata: null })); + expect(view.result.current.workspaces).toEqual([]); +}); + +test("replacement connections, retry generations and cancellation cannot apply obsolete responses", async () => { + const view = mount(); + const old = view.source; + await waitFor(() => expect(old.projectReads).toHaveLength(1)); + const next = server(); + view.rerender({ client: next.client, signal: view.lifetime.signal }); + await waitFor(() => expect(next.projectReads).toHaveLength(1)); + expect(old.projectReads[0].signal.aborted).toBe(true); + await act(async () => { + next.projectReads[0].resolve(catalog("replacement")); + next.workspaceReads[0].resolve([workspace]); + old.projectReads[0].resolve(catalog("old connection")); + old.workspaceReads[0].resolve([]); + }); + expect(view.result.current.projects).toEqual(catalog("replacement")); + expect(view.result.current.workspaces).toEqual([workspace]); + await act(async () => next.config[0].emit()); + await waitFor(() => expect(next.projectReads).toHaveLength(2)); + act(() => view.result.current.retry()); + await waitFor(() => expect(next.projectReads).toHaveLength(3)); + expect(next.config[0].signal.aborted).toBe(true); + expect(next.projectReads[1].signal.aborted).toBe(true); + await act(async () => { + next.projectReads[2].resolve(catalog("retried")); + next.workspaceReads[1].resolve([workspace]); + next.projectReads[1].resolve(catalog("old generation")); + }); + expect(view.result.current.projects).toEqual(catalog("retried")); + await act(async () => next.config[1].emit()); + await waitFor(() => expect(next.projectReads).toHaveLength(4)); + act(() => view.lifetime.abort()); + expect(next.projectReads[3].signal.aborted).toBe(true); + await act(async () => next.projectReads[3].resolve(catalog("after cancellation"))); + expect(view.result.current.projects).toEqual(catalog("retried")); +}); + +test.each([ + { kind: "config", ending: "end" }, + { kind: "config", ending: "fail" }, + { kind: "metadata", ending: "end" }, + { kind: "metadata", ending: "fail" }, +] as const)( + "a $kind subscription $ending aborts pending work and retry reconnects", + async ({ kind, ending }) => { + const view = mount(); + await view.ready(); + const { source } = view; + expect(source.config).toHaveLength(1); + await act(async () => source.config[0].emit()); + await waitFor(() => expect(source.projectReads).toHaveLength(2)); + await act(async () => source[kind][0][ending]()); + expect(view.result.current.error).not.toBeNull(); + expect(view.result.current.loading).toBe(false); + expect(source.projectReads[1].signal.aborted).toBe(true); + expect(source.metadata[0].signal.aborted).toBe(true); + await act(async () => source.projectReads[1].resolve(catalog("late after disconnect"))); + expect(view.result.current.projects).toEqual(catalog("initial")); + act(() => view.result.current.retry()); + await waitFor(() => expect(source.projectReads).toHaveLength(3)); + await act(async () => { + source.projectReads[2].resolve(catalog("healed")); + source.workspaceReads[1].resolve([workspace]); + }); + expect(view.result.current.error).toBeNull(); + expect(view.result.current.projects).toEqual(catalog("healed")); + } +); + +test("a failed refresh exposes retry without losing existing catalog or applying a later stale success", async () => { + const view = mount(); + await view.ready(); + const { source } = view; + expect(source.config).toHaveLength(1); + await act(async () => source.config[0].emit()); + await waitFor(() => expect(source.projectReads).toHaveLength(2)); + await act(async () => source.projectReads[1].reject(new Error("refresh failed"))); + expect(view.result.current.error).not.toBeNull(); + expect(view.result.current.projects).toEqual(catalog("initial")); + expect(source.config[0].signal.aborted).toBe(true); + expect(source.metadata[0].signal.aborted).toBe(true); +}); + +test("initial snapshot failure cancels the other read and retry restores both catalogs", async () => { + const view = mount(); + const { source } = view; + await waitFor(() => expect(source.workspaceReads).toHaveLength(1)); + await act(async () => source.workspaceReads[0].reject(new Error("workspace read failed"))); + expect(view.result.current.loading).toBe(false); + expect(view.result.current.error).not.toBeNull(); + expect(source.projectReads[0].signal.aborted).toBe(true); + await act(async () => source.projectReads[0].resolve(catalog("late initial result"))); + expect(view.result.current.projects).toEqual([]); + act(() => view.result.current.retry()); + await waitFor(() => expect(source.projectReads).toHaveLength(2)); + await act(async () => { + source.projectReads[1].resolve(catalog("retried")); + source.workspaceReads[1].resolve([workspace]); + }); + expect(view.result.current.loading).toBe(false); + expect(view.result.current.error).toBeNull(); + expect(view.result.current.projects).toEqual(catalog("retried")); + expect(view.result.current.workspaces).toEqual([workspace]); +}); diff --git a/packages/mobile/src/useProjects.ts b/packages/mobile/src/useProjects.ts index 35a00624bac..49e1f750c83 100644 --- a/packages/mobile/src/useProjects.ts +++ b/packages/mobile/src/useProjects.ts @@ -20,37 +20,84 @@ export function useProjects(client: MobileClient, signal: AbortSignal) { setLoading(false); return; } + let projectRequest: AbortController | null = null; + let projectsLoaded = false; + let workspacesLoaded = false; + function finishLoading() { + if (projectsLoaded && workspacesLoaded) setLoading(false); + } + function fail(cause: unknown) { + if (controller.signal.aborted) return; + controller.abort(); + setError(cause instanceof Error ? cause.message : "Could not load projects or workspaces."); + setLoading(false); + } + function refreshProjects() { + if (controller.signal.aborted) return; + // Keep consuming invalidations while reading: an older response must never + // overwrite a newer catalog used by the navigator and workspace picker. + projectRequest?.abort(); + const request = linkedAbortController(controller.signal); + projectRequest = request; + client.projects + .list(undefined, { signal: request.signal }) + .then( + (projectList) => { + if (request.signal.aborted) return; + // Scratch chats have their own creation path, not a git worktree target. + setProjects(projectList.filter(([path]) => path !== SCRATCH_PROJECT_CONFIG_KEY)); + projectsLoaded = true; + finishLoading(); + }, + (cause: unknown) => { + if (!request.signal.aborted) fail(cause); + } + ) + .finally(() => request.abort()); + } async function load() { - // Subscribe before listing so metadata changes during the snapshot are not lost. - const events = await client.workspace.onMetadata(undefined, { signal: controller.signal }); - const [projectList, workspaceList] = await Promise.all([ - client.projects.list(undefined, { signal: controller.signal }), - client.workspace.list(undefined, { signal: controller.signal }), + // Register both sources before reading either snapshot so changes cannot be missed. + const [events, configEvents] = await Promise.all([ + client.workspace.onMetadata(undefined, { signal: controller.signal }), + client.config.onConfigChanged(undefined, { signal: controller.signal }), ]); if (controller.signal.aborted) return; - // Scratch chats have their own creation path, not a git worktree target. - setProjects(projectList.filter(([path]) => path !== SCRATCH_PROJECT_CONFIG_KEY)); - setWorkspaces(workspaceList); - setLoading(false); - for await (const event of events) { - if (controller.signal.aborted) return; - setWorkspaces((current) => { - const rest = current.filter((workspace) => workspace.id !== event.workspaceId); - return event.metadata && - !isWorkspaceArchived(event.metadata.archivedAt, event.metadata.unarchivedAt) - ? [...rest, event.metadata] - : rest; - }); - } - if (!controller.signal.aborted) - throw new Error("Workspace updates disconnected. Refresh the list to reconnect."); + const watching = Promise.all([ + (async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Notifications have no payload. + for await (const _ of configEvents) { + if (controller.signal.aborted) return; + refreshProjects(); + } + if (!controller.signal.aborted) + throw new Error("Project updates disconnected. Refresh the list to reconnect."); + })(), + (async () => { + const workspaceList = await client.workspace.list(undefined, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + setWorkspaces(workspaceList); + workspacesLoaded = true; + finishLoading(); + for await (const event of events) { + if (controller.signal.aborted) return; + setWorkspaces((current) => { + const rest = current.filter((workspace) => workspace.id !== event.workspaceId); + return event.metadata && + !isWorkspaceArchived(event.metadata.archivedAt, event.metadata.unarchivedAt) + ? [...rest, event.metadata] + : rest; + }); + } + if (!controller.signal.aborted) + throw new Error("Workspace updates disconnected. Refresh the list to reconnect."); + })(), + ]); + refreshProjects(); + await watching; } - load().catch((cause: unknown) => { - if (!controller.signal.aborted) { - setError(cause instanceof Error ? cause.message : "Could not load workspaces."); - setLoading(false); - } - }); + load().catch(fail); return () => controller.abort(); }, [client, signal, generation]); return { From c5bf5c20c955421a72561484489870549256724e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 00:23:03 +0000 Subject: [PATCH 58/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20submit=20fi?= =?UTF-8?q?nal=20fields=20and=20gate=20queued=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route scratch-title and project-base Done actions through the existing guarded creation handler, retaining project Next focus navigation and platform IME submission handling. Disable queued live question forms until execution actually starts, while preserving parent eligibility checks and recovered partials. Validation: all three new regressions failed before the fix and now pass, covering real RN Web fields, validation, IME, duplicate inputs, transcript execution-start events, queued answer RPC suppression, and partial recovery. Full forms suite, mobile types/lint/format, and root static-check pass. Full mobile-check remains blocked only by two separately-owned session live-route fixtures that omit execution-start metadata (131 pass, 1 skip, 1 session wrapper failure). --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$93.92`_ --- packages/mobile/src/components/Message.tsx | 5 +- .../mobile/src/screens/CreateWorkspace.tsx | 3 +- .../mobile/src/screens/forms.behavior.tsx | 197 +++++++++++++++++- 3 files changed, 202 insertions(+), 3 deletions(-) diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index a6a6a7424f7..d2111bec0a4 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -179,6 +179,9 @@ function Tool(props: { .replace(/^./, (letter) => letter.toUpperCase()); const hint = toolHint(props.part.input); const status = toolStatus(props.part, props.streaming, props.interrupted); + // Live tools execute serially: queued questions are not registered for answers yet. + // Recovered partials rely on the parent's eligibility check instead of an execution timestamp. + const waitingForExecution = props.streaming && props.part.executionStartedAt == null; return ( props.onAnswer(props.part.toolCallId, answers)} /> )} diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx index b2593be39e2..24b2c0f5d62 100644 --- a/packages/mobile/src/screens/CreateWorkspace.tsx +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -206,7 +206,7 @@ export function CreateWorkspace(props: { editable={!busy} autoCapitalize="sentences" returnKeyType={project ? "next" : "done"} - onSubmitEditing={() => branchInput.current?.focus()} + onSubmitEditing={project ? () => branchInput.current?.focus() : create} /> {project && ( <> @@ -229,6 +229,7 @@ export function CreateWorkspace(props: { placeholder="Select or enter a branch" editable={!busy && !loading} returnKeyType="done" + onSubmitEditing={create} /> {branches.length > 0 && ( diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index e0437e6f8c3..5b8a5d85731 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -17,6 +17,7 @@ import { ChangesScreen } from "./ChangesScreen"; import { ModelSettings } from "./ModelSettings"; import { ConversationScreen } from "./ConversationScreen"; import type { WorkspaceChatMessage } from "../transcript"; +import { applyChatEvent, createTranscriptState } from "../transcript"; import { Navigator } from "./Navigator"; import { SettingsScreen } from "./SettingsScreen"; import type { ChatSettings, SettingsData } from "../settings"; @@ -343,6 +344,90 @@ test("workspace creation cannot be dismissed or submitted twice while the server expect(selected).toBe(workspace); }); +test.each([false, true])( + "workspace final-field Done creates once with validation and IME protection (project=%s)", + async (project) => { + const calls: Array<{ method: string; input: unknown }> = []; + let resolve!: (value: { success: true; metadata: FrontendWorkspaceMetadata }) => void; + const created = new Promise<{ success: true; metadata: FrontendWorkspaceMetadata }>((done) => { + resolve = done; + }); + const selected: FrontendWorkspaceMetadata[] = []; + const client = createORPCClient({ + call: async (path, input) => { + const method = path.join("."); + if (method === "projects.listBranches") + return { branches: ["main"], recommendedTrunk: "main" }; + if (method !== (project ? "workspace.create" : "workspace.createScratch")) + throw new Error(`Unexpected procedure ${method}`); + calls.push({ method, input }); + return created; + }, + }); + const signal = new AbortController().signal; + const renderForm = (connected: boolean) => ( + {}} + onClose={() => {}} + onCreated={(value) => { + selected.push(value); + }} + /> + ); + const view = render(renderForm(false)); + const title = view.getByLabelText("Title (optional)"); + fireEvent.change(title, { target: { value: "New task" } }); + let finalInput = title; + if (project) { + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "Example" })); + await waitFor(() => expect(view.getByDisplayValue("main")).toBeDefined()); + title.focus(); + fireEvent.keyDown(title, { key: "Enter", keyCode: 13 }); + expect(document.activeElement).toBe(view.getByLabelText("Branch name (optional)")); + fireEvent.keyDown(view.getByLabelText("Branch name (optional)"), { + key: "Enter", + keyCode: 13, + }); + finalInput = view.getByLabelText("Base branch"); + expect(document.activeElement).toBe(finalInput); + } + fireEvent.keyDown(finalInput, { key: "Enter", keyCode: 13 }); + expect(calls).toHaveLength(0); + view.rerender(renderForm(true)); + if (project) { + fireEvent.change(finalInput, { target: { value: " " } }); + fireEvent.keyDown(finalInput, { key: "Enter", keyCode: 13 }); + expect(calls).toHaveLength(0); + fireEvent.change(finalInput, { target: { value: "main" } }); + } + fireEvent.keyDown(finalInput, { key: "Enter", keyCode: 13, isComposing: true }); + fireEvent.keyDown(finalInput, { key: "Enter", keyCode: 229 }); + expect(calls).toHaveLength(0); + fireEvent.keyDown(finalInput, { key: "Enter", keyCode: 13 }); + expect(calls).toHaveLength(1); + fireEvent.keyDown(finalInput, { key: "Enter", keyCode: 13 }); + fireEvent.click( + view.getByRole("button", { name: project ? "Create worktree" : "Create scratch chat" }) + ); + expect(calls).toHaveLength(1); + expect(calls[0].input).toMatchObject( + project + ? { projectPath: "/project", title: "New task", trunkBranch: "main" } + : { title: "New task" } + ); + await act(async () => { + resolve({ success: true, metadata: workspace }); + await created; + }); + expect(selected).toEqual([workspace]); + } +); + const pickerValue: ChatSettings = { agentId: "exec", model: "local:one", @@ -870,6 +955,7 @@ test("question answers remain inline and require complete input before submissio toolCallId: "question", toolName: "ask_user_question", state: "input-available", + executionStartedAt: 0, input: { questions: [ { @@ -1020,6 +1106,112 @@ function prefilledQuestionPart( }; } +test("queued live questions cannot answer until their own execution starts, while partial recovery remains available", async () => { + const answers: Array = []; + const client = createORPCClient({ + call: async (path, input) => { + if (path.join(".") !== "workspace.answerAskUserQuestion") + throw new Error("Unexpected procedure"); + answers.push(input); + return { success: true }; + }, + }); + const onAnswer = async (toolCallId: string, value: Record) => { + await client.workspace.answerAskUserQuestion({ + workspaceId: "workspace", + toolCallId, + answers: value, + }); + }; + let transcript = applyChatEvent(createTranscriptState(), { + type: "stream-start", + workspaceId: "workspace", + messageId: "parallel", + historySequence: 1, + startTime: 0, + model: "local:one", + }); + for (const id of ["first", "second"]) { + const part = prefilledQuestionPart({ [id]: "main" }, false, id, id); + transcript = applyChatEvent(transcript, { + type: "tool-call-start", + workspaceId: "workspace", + messageId: "parallel", + toolCallId: id, + toolName: "ask_user_question", + args: part.input, + tokens: 1, + timestamp: 0, + }); + } + const renderMessage = (canAnswer = true) => ( + + ); + const view = render(renderMessage()); + const second = within(view.getByRole("radiogroup", { name: "second" })); + fireEvent.click(second.getByRole("radio", { name: "next" })); + expect(second.getByRole("radio", { name: "main" }).getAttribute("aria-checked")).toBe("true"); + for (const button of view.getAllByRole("button", { name: "Send answers" })) + fireEvent.click(button); + expect(answers).toHaveLength(0); + transcript = applyChatEvent(transcript, { + type: "tool-call-execution-start", + workspaceId: "workspace", + messageId: "parallel", + toolCallId: "first", + timestamp: 0, + }); + view.rerender(renderMessage()); + expect(second.getByRole("radio", { name: "main" }).getAttribute("aria-disabled")).toBe("true"); + await act(async () => { + fireEvent.click(view.getAllByRole("button", { name: "Send answers" })[0]); + }); + expect(answers).toEqual([ + { workspaceId: "workspace", toolCallId: "first", answers: { first: "main" } }, + ]); + transcript = applyChatEvent(transcript, { + type: "tool-call-execution-start", + workspaceId: "workspace", + messageId: "parallel", + toolCallId: "second", + timestamp: 1, + }); + view.rerender(renderMessage(false)); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(answers).toHaveLength(1); + view.rerender(renderMessage()); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + }); + expect(answers[1]).toEqual({ + workspaceId: "workspace", + toolCallId: "second", + answers: { second: "main" }, + }); + view.rerender( + + ); + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + }); + expect(answers[2]).toEqual({ + workspaceId: "workspace", + toolCallId: "recovered", + answers: { "Which branch?": "main" }, + }); +}); + test.each([ { multi: false, answer: "main", choices: ["main"], other: null }, { multi: false, answer: "feature, urgent", choices: ["Other"], other: "feature, urgent" }, @@ -1080,7 +1272,10 @@ test("prefilled drafts preserve user edits across deltas but reset for a differe }); view.rerender( Date: Tue, 8 Sep 2026 00:33:56 +0000 Subject: [PATCH 59/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20preserve=20?= =?UTF-8?q?durable=20Stop=20intent=20during=20question=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project the existing server-owned user-tail Stop marker onto replayed partials, and retain user abort intent in the mobile transcript. Keep genuine post-answer crash recovery without adding another persistence obligation to Stop. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$134.29`_ --- .../mobile/src/screens/ConversationScreen.tsx | 6 +- .../mobile/src/screens/session.behavior.tsx | 44 ++++++ packages/mobile/src/transcript.test.ts | 21 +++ packages/mobile/src/transcript.ts | 7 +- src/common/orpc/schemas/message.ts | 1 + src/common/types/message.ts | 2 + src/node/services/agentSession.ts | 35 +++++ .../agentSession.userStopReplay.test.ts | 136 ++++++++++++++++++ 8 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 src/node/services/agentSession.userStopReplay.test.ts diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 01d7407d496..05f86d371b8 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -145,7 +145,9 @@ export function ConversationScreen(props: { // Historical unanswered tools may have been abandoned by a later user turn. const answerMessage = running ? transcript.messages.find((message) => message.id === transcript.streamingMessageId) - : lastMessage?.role === "assistant" && lastMessage.metadata?.partial + : lastMessage?.role === "assistant" && + lastMessage.metadata?.partial && + !lastMessage.metadata.userStopped ? lastMessage : undefined; // The saved tool result survives remount/reconnect even when the answer RPC's @@ -153,6 +155,7 @@ export function ConversationScreen(props: { const resumeTargetId = lastMessage?.role === "assistant" && lastMessage.metadata?.partial && + !lastMessage.metadata.userStopped && (resumeMessageId === lastMessage.id || lastMessage.parts.some( (part) => @@ -253,6 +256,7 @@ export function ConversationScreen(props: { signal.aborted || current.streaming || latest?.id !== messageId || + latest?.metadata?.userStopped || !latest.metadata?.partial ) return; diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index d82a63ed19f..9d688fc203c 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -658,6 +658,50 @@ function answeredPartial(id = "question", partial = true) { }; } +test("answer recovery does not reappear after an intentional Stop and reconnect", async () => { + const messages: WorkspaceChatMessage[] = [question()]; + const view = fixture(messages); + await view.select("alpha"); + view.setAnswer(async () => { + messages[0] = answeredPartial(); + view.chats.at(-1)!.events.enqueue(answered()); + return { success: true }; + }); + await submitAnswer(view); + expect(callCount(view, "resumeStream")).toBe(1); + await view.emit({ + type: "stream-start", + workspaceId: "alpha", + messageId: "question", + historySequence: 1, + startTime: 1, + model, + }); + await view.emit(messages[0]); + view.setInterrupt(async () => { + const stopped = answeredPartial(); + // The server projects its durable Stop marker when the conversation reconnects. + messages[0] = { ...stopped, metadata: { ...stopped.metadata, userStopped: true } }; + view.chats.at(-1)!.events.enqueue({ + type: "stream-abort", + workspaceId: "alpha", + messageId: "question", + abortReason: "user", + metadata: { duration: 1 }, + }); + return { success: true }; + }); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Interrupt agent" }))); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); + await act(async () => view.chats.at(-1)!.end()); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Retry" }))); + await waitFor(() => expect(view.chats).toHaveLength(2)); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + expect(callCount(view, "resumeStream")).toBe(1); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); +}); + test("replayed saved answers offer manual resume, preserve no-op/error retries, and suppress duplicate starts until reconnect", async () => { const saved = answeredPartial(); const view = fixture([saved]); diff --git a/packages/mobile/src/transcript.test.ts b/packages/mobile/src/transcript.test.ts index b97e05034da..9fcd3855a91 100644 --- a/packages/mobile/src/transcript.test.ts +++ b/packages/mobile/src/transcript.test.ts @@ -50,6 +50,27 @@ const toolEnd: Extract = { }; describe("mobile transcript", () => { + test.each(["user", "system", "startup"] as const)( + "records %s abort intent without suppressing involuntary recovery", + (abortReason) => { + const stopped = replay(start, delta("partial"), { + type: "stream-abort", + workspaceId: "w", + messageId: "a", + abortReason, + }); + expect(stopped.messages[0].metadata?.userStopped).toBe( + abortReason === "user" ? true : undefined + ); + const replayed = replay({ ...stopped.messages[0], type: "message" }); + expect(replayed.messages[0].metadata?.userStopped).toBe( + stopped.messages[0].metadata?.userStopped + ); + const resumed = applyChatEvent(stopped, start); + expect(resumed.messages[0].metadata?.userStopped).toBeUndefined(); + } + ); + test("replaces authoritative snapshots by ID and sorts by server sequence", () => { const state = replay(row("b", 3, "later"), row("u", 1, "old"), row("u", 1, "edited"), { type: "caught-up", diff --git a/packages/mobile/src/transcript.ts b/packages/mobile/src/transcript.ts index 97f1e25515a..b6736dbe7e3 100644 --- a/packages/mobile/src/transcript.ts +++ b/packages/mobile/src/transcript.ts @@ -250,7 +250,12 @@ export function applyChatEvent( ? { ...state, messages: state.messages.filter((message) => message.id !== event.messageId) } : updateMessage(state, event.messageId, (message) => ({ ...message, - metadata: { ...message.metadata, ...event.metadata, partial: true }, + metadata: { + ...message.metadata, + ...event.metadata, + partial: true, + ...(event.abortReason === "user" ? { userStopped: true as const } : {}), + }, })); return finish(next, event.messageId); } diff --git a/src/common/orpc/schemas/message.ts b/src/common/orpc/schemas/message.ts index ac1f308b467..393eb2737a5 100644 --- a/src/common/orpc/schemas/message.ts +++ b/src/common/orpc/schemas/message.ts @@ -205,6 +205,7 @@ export const MuxMessageSchema = z.object({ disableWorkspaceAgents: z.boolean().optional(), retrySendOptions: z.any().optional(), agentId: AgentIdSchema.optional().catch(undefined), + userStopped: z.literal(true).optional().catch(undefined), partial: z.boolean().optional(), synthetic: z.boolean().optional(), uiVisible: z.boolean().optional(), diff --git a/src/common/types/message.ts b/src/common/types/message.ts index f3fb3f3fedc..68e7988b92f 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -983,6 +983,8 @@ export interface MuxMetadata { // Last step's provider metadata (for context window cache display) contextProviderMetadata?: Record; systemMessageTokens?: number; // Token count for system message sent with this request (calculated by AIService) + /** Replay projection of the server's durable user Stop intent for this partial turn. */ + userStopped?: true; partial?: boolean; // Whether this message was interrupted and is incomplete synthetic?: boolean; // Whether this message was synthetically generated (e.g., [CONTINUE] sentinel) /** diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 7d5090d8a49..2e5a8d1b238 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2829,12 +2829,24 @@ export class AgentSession { let sentRowCount = 0; let emittedReplayMessages = false; + let stoppedReplayTail: { messageId: string; userMessageId: string } | undefined; + // Self-healing: persisted rows can fail the current wire schema (older // writers, schema drift, corruption). oRPC validates every event yielded to // onChat subscribers and a single invalid row terminates the iterator, // which would permanently brick workspace fetch. Skip such rows instead of // letting one bad line take down the whole transcript. const emitReplayMessage = (message: WorkspaceChatMessage): boolean => { + if ( + message.type === "message" && + message.id === stoppedReplayTail?.messageId && + !this.isBusy() && + !this.isAiStreaming() && + this.startupAutoRetryAbandon?.reason === "aborted" && + this.startupAutoRetryAbandon.userMessageId === stoppedReplayTail.userMessageId + ) { + message = { ...message, metadata: { ...message.metadata, userStopped: true } }; + } const validation = ChatMuxMessageSchema.safeParse(message); if (!validation.success) { const row = message as { id?: string; metadata?: { historySequence?: number } }; @@ -2953,6 +2965,29 @@ export class AgentSession { if (historyResult.success) { const history = historyResult.data; + if (!streamInfo && !this.isBusy()) { + // Stop already durably records this user-tail marker before acknowledgment. + // Project it instead of adding a second, fallible write to partial cleanup. + await this.loadAutoRetryState(); + const marker = this.startupAutoRetryAbandon; + const user = this.findLastRetryUserMessage(history); + const last = history.at(-1); + const latest = + partial && + partialHistorySequence != null && + partialHistorySequence >= (last?.metadata?.historySequence ?? -1) + ? partial + : last; + if ( + marker?.reason === "aborted" && + marker.userMessageId != null && + marker.userMessageId === user?.id && + latest?.role === "assistant" && + latest.metadata?.partial + ) { + stoppedReplayTail = { messageId: latest.id, userMessageId: marker.userMessageId }; + } + } epochRowCount = history.length; // Cursor-based replay: only use incremental mode when all provided cursor segments are valid. diff --git a/src/node/services/agentSession.userStopReplay.test.ts b/src/node/services/agentSession.userStopReplay.test.ts new file mode 100644 index 00000000000..b64ab6045b3 --- /dev/null +++ b/src/node/services/agentSession.userStopReplay.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import type { WorkspaceChatMessage } from "@/common/orpc/types"; +import { ChatMuxMessageSchema } from "@/common/orpc/schemas/stream"; +import { createAgentSessionHarness } from "./agentSession.testHarness"; + +const initStateManagerOverrides = { replayInit: () => Promise.resolve() }; + +for (const partialFile of [false, true]) { + test(`replay projects durable user Stop onto the latest partial without rewriting history (partialFile=${partialFile})`, async () => { + const workspaceId = "stopped-question-replay"; + const original = await createAgentSessionHarness({ workspaceId, initStateManagerOverrides }); + const { config, historyService } = original; + const older: MuxMessage = { + id: "older", + role: "assistant", + parts: [{ type: "text", text: "Older partial" }], + metadata: { partial: true }, + }; + const user = createMuxMessage("user-1", "user", "Choose a branch"); + const partial: MuxMessage = { + id: "question", + role: "assistant", + metadata: { partial: true }, + parts: [ + { + type: "dynamic-tool", + toolCallId: "question", + toolName: "ask_user_question", + state: "output-available", + input: {}, + output: { summary: "main" }, + }, + ], + }; + try { + expect((await historyService.appendToHistory(workspaceId, older)).success).toBe(true); + expect((await historyService.appendToHistory(workspaceId, user)).success).toBe(true); + expect((await historyService.appendToHistory(workspaceId, partial)).success).toBe(true); + if (partialFile) + expect((await historyService.writePartial(workspaceId, partial)).success).toBe(true); + const preferencePath = ( + original.session as unknown as { getAutoRetryPreferencePath(): string } + ).getAutoRetryPreferencePath(); + await writeFile( + preferencePath, + JSON.stringify({ + enabled: false, + startupAutoRetryAbandon: { reason: "aborted", userMessageId: user.id }, + }) + ); + await original.session.dispose(); + const restarted = await createAgentSessionHarness({ + workspaceId, + config, + historyService, + initStateManagerOverrides, + }); + try { + const rows: WorkspaceChatMessage[] = []; + await restarted.session.replayHistory(({ message }) => rows.push(message)); + const stopped = rows.find((row) => row.type === "message" && row.id === partial.id); + expect(ChatMuxMessageSchema.parse(stopped).metadata?.userStopped).toBe(true); + const prior = rows.find((row) => row.type === "message" && row.id === older.id); + expect(ChatMuxMessageSchema.parse(prior).metadata?.userStopped).toBeUndefined(); + const disk = await historyService.getHistoryFromLatestBoundary(workspaceId); + if (!disk.success) throw new Error(disk.error); + expect(disk.data.every((row) => row.metadata?.userStopped === undefined)).toBe(true); + expect( + (await historyService.readPartial(workspaceId))?.metadata?.userStopped + ).toBeUndefined(); + } finally { + await restarted.session.dispose(); + } + } finally { + await original.session.dispose(); + await original.cleanup(); + } + }); +} + +test("post-answer crashes, unrelated stops and active streams are not marked user-stopped", async () => { + for (const [marker, streaming] of [ + [undefined, false], + [{ reason: "aborted", userMessageId: "other-user" }, false], + [{ reason: "runtime_start_failed", userMessageId: "user" }, false], + [{ reason: "aborted", userMessageId: "user" }, true], + ] as const) { + const workspaceId = "recoverable-question-replay"; + const { session, historyService, cleanup } = await createAgentSessionHarness({ + workspaceId, + initStateManagerOverrides, + aiServiceOverrides: { isStreaming: () => streaming }, + }); + try { + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage("user", "user", "Choose") + ) + ).success + ).toBe(true); + const partial: MuxMessage = { + id: "answer", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "question", + toolName: "ask_user_question", + state: "output-available", + input: {}, + output: { summary: "answered" }, + }, + ], + metadata: { partial: true }, + }; + expect((await historyService.appendToHistory(workspaceId, partial)).success).toBe(true); + if (marker) { + const preferencePath = ( + session as unknown as { getAutoRetryPreferencePath(): string } + ).getAutoRetryPreferencePath(); + await writeFile(preferencePath, JSON.stringify({ startupAutoRetryAbandon: marker })); + } + const rows: WorkspaceChatMessage[] = []; + await session.replayHistory(({ message }) => rows.push(message)); + const answer = rows.find((row) => row.type === "message" && row.id === partial.id); + expect(ChatMuxMessageSchema.parse(answer).metadata?.userStopped).toBeUndefined(); + } finally { + await session.dispose(); + await cleanup(); + } + } +}); From be7aa4496b49e651b6bedf7138e766c4f0fec1a7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 00:38:39 +0000 Subject: [PATCH 60/84] =?UTF-8?q?=F0=9F=A4=96=20tests(mobile):=20model=20e?= =?UTF-8?q?xecuting=20questions=20and=20newer=20recovery=20turns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamp live question execution in session fixtures while keeping recovered partial fixtures timestamp-free. Verify an existing durable Stop marker does not suppress a subsequent user turn. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$214.62`_ --- packages/mobile/src/screens/session.behavior.tsx | 9 +++++++++ .../services/agentSession.userStopReplay.test.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index 9d688fc203c..009cc0d0417 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -343,6 +343,13 @@ test("transcript-only pending questions are read-only while live Stop remains av startTime: 1, }, question(), + { + type: "tool-call-execution-start", + workspaceId: "alpha", + messageId: "question", + toolCallId: "question", + timestamp: 1, + }, ] : [question()]; const view = fixture(messages); @@ -579,6 +586,7 @@ test.each(["ready", "route-lost", "policy-blocked", "settings-failed"] as const) tokens: 1, args: questionInput("live"), timestamp: 1, + executionStartedAt: 1, }, ]); await view.select("alpha"); @@ -1307,6 +1315,7 @@ test("live interruption remains available during pending and failed settings ref tokens: 1, args: questionInput("live"), timestamp: 1, + executionStartedAt: 1, }, ]); await view.select("alpha"); diff --git a/src/node/services/agentSession.userStopReplay.test.ts b/src/node/services/agentSession.userStopReplay.test.ts index b64ab6045b3..4a064df09a7 100644 --- a/src/node/services/agentSession.userStopReplay.test.ts +++ b/src/node/services/agentSession.userStopReplay.test.ts @@ -70,6 +70,22 @@ for (const partialFile of [false, true]) { expect( (await historyService.readPartial(workspaceId))?.metadata?.userStopped ).toBeUndefined(); + // The same durable marker must not suppress a later user turn's crash recovery. + expect((await historyService.deletePartial(workspaceId)).success).toBe(true); + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage("next-user", "user", "Continue differently") + ) + ).success + ).toBe(true); + const nextPartial = { ...partial, id: "next-answer", metadata: { partial: true } }; + expect((await historyService.appendToHistory(workspaceId, nextPartial)).success).toBe(true); + const nextRows: WorkspaceChatMessage[] = []; + await restarted.session.replayHistory(({ message }) => nextRows.push(message)); + const next = nextRows.find((row) => row.type === "message" && row.id === nextPartial.id); + expect(ChatMuxMessageSchema.parse(next).metadata?.userStopped).toBeUndefined(); } finally { await restarted.session.dispose(); } From 2ec59b410024fc3ecc4e2e4f36b4acbe5fc485c7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 00:57:57 +0000 Subject: [PATCH 61/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20block=20cre?= =?UTF-8?q?ation=20from=20removed=20project=20selections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive unavailable selection state from the existing project lookup so a live catalog removal blocks both the creation button and final-field Done without discarding project intent or drafts. Show a warning and recover naturally when the project returns or the user explicitly selects another available project. Validation: both catalog removal/restore/reselect regressions failed before the fix and pass afterward. Full forms tests, mobile-check (145 pass, 1 existing live-server skip), and root static-check including both typechecks, lint, and formatting pass. No effects, state resets, useProjects, or session edits. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$114.70`_ --- .../mobile/src/screens/CreateWorkspace.tsx | 12 ++- .../mobile/src/screens/forms.behavior.tsx | 78 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx index 24b2c0f5d62..112dbc9ab10 100644 --- a/packages/mobile/src/screens/CreateWorkspace.tsx +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -70,11 +70,16 @@ export function CreateWorkspace(props: { setError(null); } + const selectedProject = props.projects.find(([path]) => path === project); + // Catalog refreshes must block a removed selection without discarding its drafts. + const projectUnavailable = project !== null && !selectedProject; + async function create() { if ( pending.current || !props.connected || props.signal.aborted || + projectUnavailable || loading || (project && !trunk.trim()) ) @@ -115,7 +120,6 @@ export function CreateWorkspace(props: { } } - const selectedProject = props.projects.find(([path]) => path === project); const projectName = selectedProject?.[1].displayName ?? project?.split(/[\\/]/).filter(Boolean).at(-1) ?? @@ -134,6 +138,7 @@ export function CreateWorkspace(props: { disabled={ !props.connected || props.signal.aborted || + projectUnavailable || loading || (project !== null && !trunk.trim()) } @@ -197,6 +202,11 @@ export function CreateWorkspace(props: { )} + {projectUnavailable && ( + + The selected project is no longer available. Choose another project to continue. + + )} { + const calls: unknown[] = []; + const client = createORPCClient({ + call: async (path, input) => { + if (path.join(".") === "projects.listBranches") + return { branches: ["main"], recommendedTrunk: "main" }; + if (path.join(".") !== "workspace.create") + throw new Error("Must not silently create a scratch chat"); + calls.push(input); + return { success: true, metadata: workspace }; + }, + }); + const signal = new AbortController().signal; + const catalog: Parameters[0]["projects"] = [ + ["/project", { workspaces: [], displayName: "Example" }], + ["/available", { workspaces: [], displayName: "Available" }], + ]; + const renderForm = (projects: typeof catalog) => ( + {}} + onClose={() => {}} + onCreated={() => {}} + /> + ); + const view = render(renderForm(catalog)); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "Example" })); + await waitFor(() => expect(view.getByDisplayValue("main")).toBeDefined()); + fireEvent.change(view.getByLabelText("Title (optional)"), { + target: { value: "Keep this title" }, + }); + fireEvent.change(view.getByLabelText("Branch name (optional)"), { + target: { value: "keep-this-branch" }, + }); + fireEvent.change(view.getByLabelText("Base branch"), { target: { value: "release" } }); + view.rerender(renderForm(catalog.slice(1))); + const create = view.getByRole("button", { name: "Create worktree" }); + expect(create.getAttribute("aria-disabled")).toBe("true"); + expect(view.queryByRole("button", { name: "Create scratch chat" })).toBeNull(); + expect(view.getByRole("alert")).toBeDefined(); + expect(view.getByDisplayValue("Keep this title")).toBeDefined(); + expect(view.getByDisplayValue("keep-this-branch")).toBeDefined(); + expect(view.getByDisplayValue("release")).toBeDefined(); + fireEvent.click(create); + fireEvent.keyDown(view.getByLabelText("Base branch"), { key: "Enter", keyCode: 13 }); + expect(calls).toHaveLength(0); + if (recovery === "restore") { + view.rerender(renderForm(catalog)); + expect(view.getByDisplayValue("keep-this-branch")).toBeDefined(); + expect(view.getByDisplayValue("release")).toBeDefined(); + } else { + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "Available" })); + await waitFor(() => expect(view.getByDisplayValue("main")).toBeDefined()); + } + expect(view.queryByRole("alert")).toBeNull(); + expect(view.getByDisplayValue("Keep this title")).toBeDefined(); + expect( + view.getByRole("button", { name: "Create worktree" }).getAttribute("aria-disabled") + ).not.toBe("true"); + await act(async () => { + fireEvent.keyDown(view.getByLabelText("Base branch"), { key: "Enter", keyCode: 13 }); + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + projectPath: recovery === "restore" ? "/project" : "/available", + title: "Keep this title", + trunkBranch: recovery === "restore" ? "release" : "main", + }); + } +); + const pickerValue: ChatSettings = { agentId: "exec", model: "local:one", From bddc6c77b04aa780aa5ede63c7a838641171cd9d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 01:55:13 +0000 Subject: [PATCH 62/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20require=20t?= =?UTF-8?q?rusted=20worktree-capable=20project=20selections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve trust through the canonical project owner and block both creation entrypoints when trust is absent or revoked. Distinguish unresolved/failed branch discovery from a successful empty list, and require a successful nonempty branch list for worktree creation even when Base is entered manually. Direct unsupported setup and trust actions to desktop without adding new backend capabilities. Retain draft and branch state on trust changes and same-project selection while closing the chooser. Ignore cancelled late branch responses, preserve scratch creation, and add behavioral coverage for parent-owned trust, revocation/restoration, missing owners, empty/error discovery, and no-op selection. Validation: targeted eligibility regressions were red before the fix and pass afterward; full forms suite, mobile-check (145 pass, 1 existing live-server skip), and root static-check including both typechecks, lint, and formatting pass. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$149.09`_ --- .../mobile/src/screens/CreateWorkspace.tsx | 62 +++-- .../mobile/src/screens/forms.behavior.tsx | 228 +++++++++++++++++- 2 files changed, 261 insertions(+), 29 deletions(-) diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx index 112dbc9ab10..2b61e54cc73 100644 --- a/packages/mobile/src/screens/CreateWorkspace.tsx +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -8,6 +8,7 @@ import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/wor import { Button, Field, Loading, Notice, Sheet } from "../components/Controls"; import { colors, layout, radii, spacing, typography } from "../theme"; import { linkedAbortController } from "../useConnection"; +import { resolveWorkspaceCreationScope } from "../../../../src/common/utils/subProjects"; export function CreateWorkspace(props: { client: MobileClient; @@ -23,7 +24,8 @@ export function CreateWorkspace(props: { const [title, setTitle] = useState(""); const [branch, setBranch] = useState(""); const [trunk, setTrunk] = useState(""); - const [branches, setBranches] = useState([]); + // Null is unresolved/failed discovery; an empty successful list cannot back a worktree. + const [branches, setBranches] = useState(null); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -37,6 +39,7 @@ export function CreateWorkspace(props: { controller.current = abort; pending.current = false; setBusy(false); + setBranches(null); if (abort.signal.aborted || !project) { setLoading(false); return () => abort.abort(); @@ -62,28 +65,34 @@ export function CreateWorkspace(props: { function selectProject(path: string | null) { if (pending.current) return; - setProject(path); setChoosingProject(false); + if (path === project) return; + setProject(path); setBranch(""); setTrunk(""); - setBranches([]); + setBranches(null); setError(null); } - const selectedProject = props.projects.find(([path]) => path === project); + const projectsByPath = new Map(props.projects); + const selectedProject = project === null ? undefined : projectsByPath.get(project); // Catalog refreshes must block a removed selection without discarding its drafts. const projectUnavailable = project !== null && !selectedProject; + // Subprojects share their owning project's trust, regardless of their own raw flag. + const projectTrusted = + project === null || + projectsByPath.get(resolveWorkspaceCreationScope(project, projectsByPath).projectPath) + ?.trusted === true; + const repositoryUnsupported = project !== null && branches?.length === 0; + const creationDisabled = + !props.connected || + props.signal.aborted || + loading || + (project !== null && + (projectUnavailable || !projectTrusted || !branches?.length || !trunk.trim())); async function create() { - if ( - pending.current || - !props.connected || - props.signal.aborted || - projectUnavailable || - loading || - (project && !trunk.trim()) - ) - return; + if (pending.current || creationDisabled) return; pending.current = true; setBusy(true); setError(null); @@ -121,7 +130,7 @@ export function CreateWorkspace(props: { } const projectName = - selectedProject?.[1].displayName ?? + selectedProject?.displayName ?? project?.split(/[\\/]/).filter(Boolean).at(-1) ?? "Scratch chat"; return ( @@ -133,17 +142,7 @@ export function CreateWorkspace(props: { dismissDisabled={busy} footer={ <> - {busy && ( @@ -207,6 +206,17 @@ export function CreateWorkspace(props: { The selected project is no longer available. Choose another project to continue. )} + {!projectUnavailable && !projectTrusted && ( + + Trust the owning project in Xum desktop (Settings → Security) before creating a worktree. + + )} + {!projectUnavailable && projectTrusted && repositoryUnsupported && ( + + Mobile worktrees require a Git repository with an initial commit and a local branch. Set + it up in Xum desktop or your terminal, then reopen this sheet. + + )} - {branches.length > 0 && ( + {branches !== null && branches.length > 0 && ( {branches.slice(0, 6).map((name) => ( {}} onClose={() => {}} onCreated={(value) => { @@ -444,8 +444,8 @@ test.each(["restore", "reselect"])( }); const signal = new AbortController().signal; const catalog: Parameters[0]["projects"] = [ - ["/project", { workspaces: [], displayName: "Example" }], - ["/available", { workspaces: [], displayName: "Available" }], + ["/project", { workspaces: [], displayName: "Example", trusted: true }], + ["/available", { workspaces: [], displayName: "Available", trusted: true }], ]; const renderForm = (projects: typeof catalog) => ( { + const calls: unknown[] = []; + let branchReads = 0; + const client = createORPCClient({ + call: async (path, input) => { + if (path.join(".") === "projects.listBranches") { + branchReads++; + return { branches: ["main"], recommendedTrunk: "main" }; + } + if (path.join(".") !== "workspace.create") throw new Error("Unexpected creation path"); + calls.push(input); + return { success: true, metadata: workspace }; + }, + }); + const signal = new AbortController().signal; + const catalog = ( + trusted: boolean | undefined, + childTrusted: boolean, + ownerPresent = true + ): Parameters[0]["projects"] => { + const entries: Parameters[0]["projects"] = [ + ["/owner", { workspaces: [], displayName: "Owner", trusted }], + [ + "/owner/child", + { + workspaces: [], + displayName: "Child", + parentProjectPath: "/owner", + trusted: childTrusted, + }, + ], + ]; + return ownerPresent ? entries : entries.slice(1); + }; + const renderForm = ( + trusted: boolean | undefined, + childTrusted: boolean, + ownerPresent = true + ) => ( + {}} + onClose={() => {}} + onCreated={() => {}} + /> + ); + const view = render(renderForm(undefined, true)); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: kind === "root" ? "Owner" : "Child" })); + await waitFor(() => expect(view.getByDisplayValue("main")).toBeDefined()); + fireEvent.change(view.getByLabelText("Title (optional)"), { + target: { value: "Preserve trust draft" }, + }); + fireEvent.change(view.getByLabelText("Branch name (optional)"), { + target: { value: "my-branch" }, + }); + fireEvent.change(view.getByLabelText("Base branch"), { target: { value: "release" } }); + const attempt = () => { + fireEvent.click(view.getByRole("button", { name: "Create worktree" })); + fireEvent.keyDown(view.getByLabelText("Base branch"), { key: "Enter", keyCode: 13 }); + }; + expect( + view.getByRole("button", { name: "Create worktree" }).getAttribute("aria-disabled") + ).toBe("true"); + expect(view.getByRole("alert")).toBeDefined(); + attempt(); + expect(calls).toHaveLength(0); + view.rerender(renderForm(true, false)); + expect(view.queryByRole("alert")).toBeNull(); + expect( + view.getByRole("button", { name: "Create worktree" }).getAttribute("aria-disabled") + ).not.toBe("true"); + view.rerender(renderForm(false, true)); + attempt(); + expect(calls).toHaveLength(0); + if (kind === "subproject") { + view.rerender(renderForm(true, true, false)); + attempt(); + expect(calls).toHaveLength(0); + } + view.rerender(renderForm(true, false)); + expect(view.getByDisplayValue("Preserve trust draft")).toBeDefined(); + expect(view.getByDisplayValue("my-branch")).toBeDefined(); + expect(view.getByDisplayValue("release")).toBeDefined(); + expect(branchReads).toBe(1); + await act(async () => { + fireEvent.keyDown(view.getByLabelText("Base branch"), { key: "Enter", keyCode: 13 }); + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + projectPath: kind === "root" ? "/owner" : "/owner/child", + title: "Preserve trust draft", + branchName: "my-branch", + trunkBranch: "release", + }); + } +); + +test.each(["empty", "error"])( + "branch discovery %s cannot be bypassed by typing a base and does not block scratch", + async (outcome) => { + type BranchResult = Awaited>; + let resolve!: (value: BranchResult) => void; + let reject!: (reason: Error) => void; + const result = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + const calls: string[] = []; + const client = createORPCClient({ + call: async (path) => { + const method = path.join("."); + if (method === "projects.listBranches") return result; + calls.push(method); + return { success: true, metadata: workspace }; + }, + }); + const view = render( + {}} + onClose={() => {}} + onCreated={() => {}} + /> + ); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "Project" })); + expect(view.queryByRole("alert")).toBeNull(); + expect( + view.getByRole("button", { name: "Create worktree" }).getAttribute("aria-disabled") + ).toBe("true"); + await act(async () => { + if (outcome === "empty") resolve({ branches: [], recommendedTrunk: null }); + else reject(new Error("Repository read failed")); + }); + fireEvent.change(view.getByLabelText("Base branch"), { target: { value: "arbitrary-base" } }); + expect( + view.getByRole("button", { name: "Create worktree" }).getAttribute("aria-disabled") + ).toBe("true"); + const alert = view.getByRole("alert"); + if (outcome === "error") expect(alert.textContent).toContain("Repository read failed"); + fireEvent.click(view.getByRole("button", { name: "Create worktree" })); + fireEvent.keyDown(view.getByLabelText("Base branch"), { key: "Enter", keyCode: 13 }); + expect(calls).toHaveLength(0); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "Scratch chat" })); + expect(view.queryByRole("alert")).toBeNull(); + await act(async () => { + fireEvent.keyDown(view.getByLabelText("Title (optional)"), { key: "Enter", keyCode: 13 }); + }); + expect(calls).toEqual(["workspace.createScratch"]); + } +); + +test("late branch results cannot change a newly selected project's eligibility", async () => { + type BranchResult = Awaited>; + let resolveOld!: (value: BranchResult) => void; + const oldResult = new Promise((done) => { + resolveOld = done; + }); + const reads: Array = []; + const calls: unknown[] = []; + const client = createORPCClient({ + call: async (path, input, options) => { + if (path.join(".") === "projects.listBranches") { + reads.push(options.signal); + return (input as { projectPath: string }).projectPath === "/old" + ? oldResult + : { branches: ["main"], recommendedTrunk: "main" }; + } + calls.push(input); + return { success: true, metadata: workspace }; + }, + }); + const view = render( + {}} + onClose={() => {}} + onCreated={() => {}} + /> + ); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "Old" })); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "New" })); + await waitFor(() => expect(view.getByDisplayValue("main")).toBeDefined()); + expect(reads[0]?.aborted).toBe(true); + await act(async () => { + resolveOld({ branches: [], recommendedTrunk: null }); + }); + expect(view.queryByRole("alert")).toBeNull(); + expect(view.getByDisplayValue("main")).toBeDefined(); + fireEvent.change(view.getByLabelText("Branch name (optional)"), { + target: { value: "keep-this-draft" }, + }); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "New" })); + expect(view.queryByRole("button", { name: "New" })).toBeNull(); + expect(view.getByDisplayValue("keep-this-draft")).toBeDefined(); + expect(view.getByDisplayValue("main")).toBeDefined(); + await act(async () => { + fireEvent.keyDown(view.getByLabelText("Base branch"), { key: "Enter", keyCode: 13 }); + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ projectPath: "/new", trunkBranch: "main" }); +}); + const pickerValue: ChatSettings = { agentId: "exec", model: "local:one", From 6f65c26890772756cc2f1627cc63db4211488e6b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 02:54:16 +0000 Subject: [PATCH 63/84] =?UTF-8?q?=F0=9F=A4=96=20fix(chat):=20use=20pinned?= =?UTF-8?q?=20active=20context=20limits=20in=20desktop=20meters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain backend contextWindowTokens on active/replayed stream starts and fallback metadata updates, expose it through WorkspaceUsage, and pass it to the composer and sidebar token meters. Preserve explicit unknown limits, legacy/historical selected-model fallbacks, and active-attempt usage resets. Add a behavioral store-to-meter regression for settings/catalog drift, replay, fallback capacity changes, unknown capacity, and return to idle/history behavior. Validation with Bun 1.3.5: 295 targeted desktop store/aggregator/dispatch/token-meter tests and full static-check passed. No mobile, auth, provider API, or unrelated main changes. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$177.49`_ --- src/browser/features/ChatInput/index.tsx | 13 +- .../RightSidebar/ContextUsageSection.tsx | 3 +- src/browser/stores/WorkspaceStore.test.ts | 127 +++++++++++++++++- src/browser/stores/WorkspaceStore.ts | 4 + .../messages/StreamingMessageAggregator.ts | 7 + 5 files changed, 150 insertions(+), 4 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 50fd2cb3248..f805325e118 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -604,11 +604,20 @@ const ChatInputInner: React.FC = (props) => { const activeUsageModel = usage?.liveUsage?.model ?? null; const contextDisplayModel = activeUsageModel ?? baseModel; const use1M = has1MContext(contextDisplayModel); + // A live pin belongs to live tokens, not the historical estimate shown between attempts. + const liveContextWindowTokens = usage?.liveUsage ? usage.liveContextWindowTokens : undefined; const contextUsageData = useMemo(() => { return lastUsage - ? calculateTokenMeterData(lastUsage, contextDisplayModel, use1M, false, providersConfig) + ? calculateTokenMeterData( + lastUsage, + contextDisplayModel, + use1M, + false, + providersConfig, + liveContextWindowTokens + ) : { segments: [], totalTokens: 0, totalPercentage: 0 }; - }, [lastUsage, contextDisplayModel, use1M, providersConfig]); + }, [lastUsage, contextDisplayModel, use1M, providersConfig, liveContextWindowTokens]); const autoCompactionProps = useAutoCompactionSettings(workspaceIdForUsage, contextDisplayModel); // Idle compaction settings (per-project, persisted to backend for idleCompactionService) diff --git a/src/browser/features/RightSidebar/ContextUsageSection.tsx b/src/browser/features/RightSidebar/ContextUsageSection.tsx index 963f6ed5731..d356c33a8fc 100644 --- a/src/browser/features/RightSidebar/ContextUsageSection.tsx +++ b/src/browser/features/RightSidebar/ContextUsageSection.tsx @@ -58,7 +58,8 @@ export const ContextUsageSection: React.FC = ({ worksp contextDisplayModel, has1MContext(contextDisplayModel), false, - providersConfig + providersConfig, + usage.liveUsage ? usage.liveContextWindowTokens : undefined ); // Warn when the compaction model can't fit the auto-compact threshold to avoid failures. diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index cc7c7a43cb2..f2331ffedeb 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -28,7 +28,12 @@ import { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMe import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { WorkflowRunRecord } from "@/common/types/workflow"; import type { StreamStartEvent, ToolCallStartEvent } from "@/common/types/stream"; -import type { WorkspaceActivitySnapshot, WorkspaceChatMessage } from "@/common/orpc/types"; +import type { + ProvidersConfigMap, + WorkspaceActivitySnapshot, + WorkspaceChatMessage, +} from "@/common/orpc/types"; +import { calculateTokenMeterData } from "@/common/utils/tokens/tokenMeterUtils"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT } from "@/common/constants/ui"; import { @@ -1502,6 +1507,126 @@ describe("WorkspaceStore", () => { }); describe("live usage identity pinning", () => { + it("pins desktop meter capacity across settings, replay and fallback attempts, but not idle history", async () => { + const workspaceId = "desktop-pinned-capacity"; + createAndAddWorkspace(store, workspaceId); + await tick(10); + const aggregator = store.getAggregator(workspaceId); + if (!aggregator) throw new Error("Expected workspace aggregator"); + const internal = getInternal<{ + processStreamEvent: ( + id: string, + target: typeof aggregator, + event: WorkspaceChatMessage + ) => void; + }>(store); + const dispatch = (event: WorkspaceChatMessage) => + internal.processStreamEvent(workspaceId, aggregator, event); + const model = "anthropic:claude-sonnet-4-20250514"; + const reportedUsage = { inputTokens: 50_000, outputTokens: 0, totalTokens: 50_000 }; + const providers: ProvidersConfigMap = { + anthropic: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: [{ id: "claude-sonnet-4-20250514", contextWindowTokens: 200_000 }], + }, + }; + let use1M = false; + const meter = () => { + const usage = store.getWorkspaceUsage(workspaceId); + return calculateTokenMeterData( + usage.liveUsage ?? usage.lastContextUsage, + usage.liveUsage?.model ?? model, + use1M, + false, + providers, + usage.liveUsage ? usage.liveContextWindowTokens : undefined + ); + }; + const start: StreamStartEvent = { + type: "stream-start", + workspaceId, + messageId: "active", + model, + metadataModel: model, + historySequence: 1, + startTime: 1, + contextWindowTokens: 500_000, + }; + const delta: WorkspaceChatMessage = { + type: "usage-delta", + workspaceId, + messageId: "active", + usage: reportedUsage, + cumulativeUsage: reportedUsage, + }; + dispatch(start); + dispatch(delta); + expect(store.getWorkspaceUsage(workspaceId).liveContextWindowTokens).toBe(500_000); + expect(meter().totalPercentage).toBe(10); + providers.anthropic.models = [ + { id: "claude-sonnet-4-20250514", contextWindowTokens: 100_000 }, + ]; + use1M = true; + expect(meter().totalPercentage).toBe(10); + dispatch({ + type: "stream-delta", + workspaceId, + messageId: "active", + delta: "preserved", + timestamp: 2, + tokens: 1, + }); + dispatch({ ...start, replay: true }); + expect(meter().totalPercentage).toBe(10); + const fallbackModel = "openai:gpt-4o"; + const fallback: WorkspaceChatMessage = { + type: "stream-metadata", + workspaceId, + messageId: "active", + metadata: { + model: fallbackModel, + metadataModel: fallbackModel, + contextWindowTokens: 250_000, + routedThroughGateway: false, + routeProvider: null, + }, + }; + dispatch(fallback); + expect(store.getWorkspaceUsage(workspaceId).liveUsage).toBeUndefined(); + expect(store.getWorkspaceUsage(workspaceId).liveCostUsage).toBeUndefined(); + expect(store.getWorkspaceUsage(workspaceId).liveContextWindowTokens).toBe(250_000); + dispatch(delta); + expect(meter().totalPercentage).toBe(20); + dispatch({ ...fallback, metadata: { ...fallback.metadata, contextWindowTokens: null } }); + dispatch(delta); + expect(store.getWorkspaceUsage(workspaceId).liveContextWindowTokens).toBeNull(); + expect(meter().maxTokens).toBeUndefined(); + dispatch({ + ...start, + model: fallbackModel, + metadataModel: fallbackModel, + replay: true, + contextWindowTokens: null, + }); + expect(meter().maxTokens).toBeUndefined(); + dispatch({ + type: "stream-end", + workspaceId, + messageId: "active", + parts: [{ type: "text", text: "preserved" }], + metadata: { model, contextWindowTokens: 500_000, contextUsage: reportedUsage }, + }); + expect(store.getWorkspaceUsage(workspaceId).liveContextWindowTokens).toBeUndefined(); + use1M = false; + expect(meter().totalPercentage).toBe(50); + // Legacy active starts lack a pin and retain the existing configured-limit fallback. + dispatch({ ...start, messageId: "legacy", contextWindowTokens: undefined }); + dispatch({ ...delta, messageId: "legacy" }); + expect(meter().totalPercentage).toBe(50); + }); + it("prices live Coder usage via the stream's pinned metadataModel", async () => { const workspaceId = "live-coder-usage-pinned"; createAndAddWorkspace(store, workspaceId); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index a18ee621e82..b6ea7fd95a3 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -326,6 +326,8 @@ export interface WorkspaceUsageState { * it over re-resolving the raw model against a refreshed providers config. */ liveMetadataModel?: string; + /** Backend-pinned active request capacity: null is unknown; undefined is legacy or idle. */ + liveContextWindowTokens?: number | null; } /** @@ -2872,6 +2874,7 @@ export class WorkspaceStore { // re-resolving the raw model against the refreshed config would price // and bucket live usage differently from the backend ledger. const liveMetadataModel = aggregator.getActiveStreamMetadataModel(); + const liveContextWindowTokens = aggregator.getActiveStreamContextWindowTokens(); const rawContextUsage = activeStreamId ? aggregator.getActiveStreamUsage(activeStreamId) : undefined; @@ -2913,6 +2916,7 @@ export class WorkspaceStore { liveUsage, liveCostUsage, liveMetadataModel, + liveContextWindowTokens, }; }); } diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 94f8bc6b387..e3c34d951df 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -251,6 +251,7 @@ interface StreamingContext { * stream is active. */ metadataModel?: string; + contextWindowTokens?: number | null; routedThroughGateway?: boolean; routeProvider?: string; @@ -1935,6 +1936,10 @@ export class StreamingMessageAggregator { return this.getActiveStreamEntry()?.[1].metadataModel; } + getActiveStreamContextWindowTokens(): number | null | undefined { + return this.getActiveStreamEntry()?.[1].contextWindowTokens; + } + getCurrentModel(): string | undefined { const activeStream = this.getActiveStreamEntry(); if (activeStream) { @@ -2107,6 +2112,7 @@ export class StreamingMessageAggregator { isReplay: data.replay === true, model: data.model, metadataModel: data.metadataModel, + contextWindowTokens: data.contextWindowTokens, routedThroughGateway: data.routedThroughGateway, routeProvider, serverFirstTokenTime: null, @@ -2190,6 +2196,7 @@ export class StreamingMessageAggregator { const metadata = copyStreamMetadataSnapshot(data.metadata); context.model = metadata.model; context.metadataModel = metadata.metadataModel; + context.contextWindowTokens = metadata.contextWindowTokens; context.thinkingLevel = metadata.thinkingLevel; context.routedThroughGateway = metadata.routedThroughGateway; context.routeProvider = metadata.routeProvider; From ccdda2813381d91ba4081fdff51b7ea8944bc6fc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 02:54:21 +0000 Subject: [PATCH 64/84] =?UTF-8?q?=F0=9F=A4=96=20fix:=20isolate=20mobile=20?= =?UTF-8?q?drafts=20and=20hide=20model-only=20transcript=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep workspace-keyed draft subscriptions beneath the navigator and preserve raw wire history while filtering only display rows. Cover real navigator updates, workspace-switch/disconnect draft lifetime, hidden live/replay rows, pagination cursors, and tool/recovery state. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$1462.16`_ --- packages/mobile/App.tsx | 34 ++++---- .../mobile/src/screens/ConversationScreen.tsx | 3 +- .../src/screens/navigatorTestProfiler.tsx | 15 ++++ .../mobile/src/screens/session.behavior.tsx | 78 +++++++++++++++++++ packages/mobile/src/screens/session.test.ts | 2 + packages/mobile/src/sessionDrafts.ts | 43 ++++++++++ packages/mobile/src/transcript.test.ts | 59 +++++++++++++- packages/mobile/src/transcript.ts | 11 +++ packages/mobile/src/useConversation.test.ts | 46 ++++++++++- 9 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 packages/mobile/src/screens/navigatorTestProfiler.tsx create mode 100644 packages/mobile/src/sessionDrafts.ts diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx index ddfb46529cb..b90feecdba4 100644 --- a/packages/mobile/App.tsx +++ b/packages/mobile/App.tsx @@ -1,5 +1,5 @@ -import { createContext, useContext, useRef, useState } from "react"; -import type { ReactNode, SetStateAction } from "react"; +import { createContext, useContext, useRef, useState, useSyncExternalStore } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { StatusBar, Text, useWindowDimensions, View } from "react-native"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { DarkTheme, NavigationContainer } from "@react-navigation/native"; @@ -19,8 +19,7 @@ import { KeyboardProvider } from "./src/components/Keyboard"; import { useProjects } from "./src/useProjects"; import { useConnection } from "./src/useConnection"; import { colors, layout, WIDE_LAYOUT_MIN_WIDTH } from "./src/theme"; -import { EMPTY_DRAFT } from "./src/draft"; -import type { ChatDraft } from "./src/draft"; +import { createSessionDrafts } from "./src/sessionDrafts"; import type { ChatSettings } from "./src/settings"; export type MobileRoutes = { @@ -34,10 +33,9 @@ const Stack = createNativeStackNavigator(); type SessionContext = { session: ReturnType; data: ReturnType; - drafts: Record; + drafts: ReturnType; selections: Record; setSelection: (id: string, value: ChatSettings) => void; - setDraft: (id: string, update: SetStateAction) => void; create: (onCreated: (workspace: FrontendWorkspaceMetadata) => void) => void; disconnect: () => Promise; disconnectError: string | null; @@ -72,7 +70,7 @@ export function ConnectedApp(props: { connection: Connection; onDisconnect: () = const session = useConnection(props.connection); const data = useProjects(session.connection.client, session.signal); // Full drafts and unsent model choices survive native back/pop and reconnection. - const [drafts, setDrafts] = useState>({}); + const [drafts] = useState(createSessionDrafts); const [selections, setSelections] = useState>({}); const [onCreated, setOnCreated] = useState< ((workspace: FrontendWorkspaceMetadata) => void) | null @@ -95,6 +93,7 @@ export function ConnectedApp(props: { connection: Connection; onDisconnect: () = return; } session.cancel(); + drafts.clear(); props.onDisconnect(); } const value: SessionContext = { @@ -108,12 +107,6 @@ export function ConnectedApp(props: { connection: Connection; onDisconnect: () = disconnect, disconnectError, disconnecting, - setDraft(id, update) { - setDrafts((current) => { - const next = typeof update === "function" ? update(current[id] ?? EMPTY_DRAFT) : update; - return current[id] === next ? current : { ...current, [id]: next }; - }); - }, create(callback) { if (session.ready) setOnCreated(() => callback); }, @@ -247,8 +240,17 @@ function ScreenLayout(props: { ); } +function DraftConversation( + props: Omit, "draft" | "onDraftChange"> +) { + const { drafts } = useSession(); + const store = drafts.get(props.workspace.id); + const draft = useSyncExternalStore(store.subscribe, store.getSnapshot); + return ; +} + function ConversationRoute(props: NativeStackScreenProps) { - const { session, data, drafts, setDraft, selections, setSelection } = useSession(); + const { session, data, selections, setSelection } = useSession(); const { workspaceId } = props.route.params; const workspace = data.workspaces.find((item) => item.id === workspaceId); return ( @@ -256,7 +258,7 @@ function ConversationRoute(props: NativeStackScreenProps} {session.error && {session.error}} {workspace ? ( - props.navigation.navigate("Settings")} selection={selections[workspaceId] ?? null} onSelectionChange={(value) => setSelection(workspaceId, value)} - draft={drafts[workspaceId] ?? EMPTY_DRAFT} - onDraftChange={(update) => setDraft(workspaceId, update)} /> ) : ( <> diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 05f86d371b8..d87604b56d5 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -27,6 +27,7 @@ import { ContextUsage } from "../components/ContextUsage"; import { getContextMeterData } from "../contextUsage"; import { getWebComposerKeyAction } from "../composerKeyboard"; import { useConversation } from "../useConversation"; +import { getVisibleMessages } from "../transcript"; import { linkedAbortController } from "../useConnection"; import { getModelBlockReason, @@ -381,7 +382,7 @@ export function ConversationScreen(props: { message.id} contentContainerStyle={styles.messages} keyboardShouldPersistTaps="handled" diff --git a/packages/mobile/src/screens/navigatorTestProfiler.tsx b/packages/mobile/src/screens/navigatorTestProfiler.tsx new file mode 100644 index 00000000000..7948546497e --- /dev/null +++ b/packages/mobile/src/screens/navigatorTestProfiler.tsx @@ -0,0 +1,15 @@ +import { mock } from "bun:test"; +import { Profiler } from "react"; +import type { ComponentProps } from "react"; +import { Navigator } from "./Navigator"; + +// Profile the real navigator tree, including the retained Workspaces route. +export const navigatorUpdates = { count: 0 }; +const RealNavigator = Navigator; +mock.module("./Navigator", () => ({ + Navigator: (props: ComponentProps) => ( + navigatorUpdates.count++}> + + + ), +})); diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index 009cc0d0417..7db7aba0888 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -1,3 +1,4 @@ +import { navigatorUpdates } from "./navigatorTestProfiler"; import { secureStore, stackState } from "./sessionTestPlatform"; import { afterEach, describe, expect, test } from "bun:test"; import { act, cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; @@ -235,6 +236,83 @@ function fixture( }; } +test("replay and live model-only rows stay hidden while explicit notices remain visible", async () => { + const hidden: WorkspaceChatMessage = { + type: "message", + id: "hidden", + role: "user", + parts: [{ type: "text", text: "Internal model instructions" }], + metadata: { historySequence: 1, synthetic: true }, + }; + const notice: WorkspaceChatMessage = { + ...hidden, + id: "notice", + parts: [{ type: "text", text: "Visible system notice" }], + metadata: { historySequence: 2, synthetic: true, uiVisible: true }, + }; + const workflow: WorkspaceChatMessage = { + ...hidden, + id: "workflow", + parts: [{ type: "text", text: "Internal workflow payload" }], + metadata: { + historySequence: 3, + muxMetadata: { type: "workflow-result", rawCommand: "/run", runId: "wfr_test" }, + }, + }; + const view = fixture([hidden, notice, workflow]); + await view.select("alpha"); + expect(view.queryByText("Internal model instructions")).toBeNull(); + expect(view.queryByText("Internal workflow payload")).toBeNull(); + expect(view.getByText("Visible system notice")).toBeDefined(); + await view.emit({ + ...hidden, + id: "live", + parts: [{ type: "text", text: "Live model instructions" }], + }); + expect(view.queryByText("Live model instructions")).toBeNull(); + await view.emit({ + ...workflow, + id: "live-workflow", + parts: [{ type: "text", text: "Live workflow payload" }], + }); + expect(view.queryByText("Live workflow payload")).toBeNull(); + await view.emit({ ...notice, id: "live-notice", parts: [{ type: "text", text: "Live notice" }] }); + expect(view.getByText("Live notice")).toBeDefined(); +}); + +test("typing in a wide many-workspace session does not update either navigator", async () => { + const manyWorkspaces = Array.from({ length: 100 }, (_, i) => ({ + ...workspaces[0], + id: `workspace-${i}`, + name: `workspace-${i}`, + })); + const view = fixture([], true, disabledPolicy, manyWorkspaces); + await view.select("workspace-0"); + expect(view.getByRole("button", { name: "project, 100 workspaces" })).toBeDefined(); + const before = navigatorUpdates.count; + expect(before).toBeGreaterThan(0); + const input = view.getByLabelText("Message"); + for (const value of ["d", "dr", "dra", "draf", "draft"]) { + await act(async () => fireEvent.change(input, { target: { value } })); + expect(input).toHaveProperty("value", value); + } + expect(navigatorUpdates.count).toBe(before); + await view.select("workspace-1"); + expect(view.getByLabelText("Message")).toHaveProperty("value", ""); + await view.select("workspace-0"); + expect(view.getByLabelText("Message")).toHaveProperty("value", "draft"); + // A disconnected session must not leak its drafts into the next login. + fireEvent.click(view.getAllByRole("button", { name: "Settings" }).at(-1)!); + fireEvent.click(view.getByRole("button", { name: "Disconnect" })); + await act(async () => + fireEvent.click(view.getByRole("button", { name: "Disconnect & forget credentials" })) + ); + expect(view.disconnected).toBe(1); + const next = fixture([], true, disabledPolicy, manyWorkspaces); + await next.select("workspace-0"); + expect(next.getByLabelText("Message")).toHaveProperty("value", ""); +}); + test("searched delegated workspaces keep legacy/current identity locked while allowing model changes", async () => { for (const [identity, expected, label] of [ [{ agentType: "explore" }, "explore", "Explore"], diff --git a/packages/mobile/src/screens/session.test.ts b/packages/mobile/src/screens/session.test.ts index d97022d443c..dcbfd335c82 100644 --- a/packages/mobile/src/screens/session.test.ts +++ b/packages/mobile/src/screens/session.test.ts @@ -13,6 +13,8 @@ test("mobile session and recovery behavior", async () => { "./src/screens/formTestPlatform.ts", "--preload", "./src/screens/sessionTestPlatform.tsx", + "--preload", + "./src/screens/navigatorTestProfiler.tsx", "./src/screens/session.behavior.tsx", ], { cwd: fileURLToPath(new URL("../../", import.meta.url)), stdout: "pipe", stderr: "pipe" } diff --git a/packages/mobile/src/sessionDrafts.ts b/packages/mobile/src/sessionDrafts.ts new file mode 100644 index 00000000000..dd23c8069f1 --- /dev/null +++ b/packages/mobile/src/sessionDrafts.ts @@ -0,0 +1,43 @@ +import type { SetStateAction } from "react"; +import { EMPTY_DRAFT } from "./draft"; +import type { ChatDraft } from "./draft"; + +function createDraft() { + let value = EMPTY_DRAFT; + const listeners = new Set<() => void>(); + return { + getSnapshot: () => value, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + set(update: SetStateAction) { + const next = typeof update === "function" ? update(value) : update; + if (next === value) return; + value = next; + for (const listener of listeners) listener(); + }, + }; +} + +// Session-owned, workspace-keyed drafts survive route unmounts without broadcasting +// every keystroke to the workspace navigator or other mounted conversations. +export function createSessionDrafts() { + const drafts = new Map>(); + return { + get(id: string) { + let draft = drafts.get(id); + if (!draft) { + draft = createDraft(); + drafts.set(id, draft); + } + return draft; + }, + clear() { + for (const draft of drafts.values()) draft.set(EMPTY_DRAFT); + drafts.clear(); + }, + }; +} diff --git a/packages/mobile/src/transcript.test.ts b/packages/mobile/src/transcript.test.ts index 9fcd3855a91..3545f748220 100644 --- a/packages/mobile/src/transcript.test.ts +++ b/packages/mobile/src/transcript.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { applyChatEvent, createTranscriptState, type WorkspaceChatMessage } from "./transcript"; +import { + applyChatEvent, + createTranscriptState, + getVisibleMessages, + type WorkspaceChatMessage, +} from "./transcript"; const start: Extract = { type: "stream-start", @@ -50,6 +55,58 @@ const toolEnd: Extract = { }; describe("mobile transcript", () => { + test("visibility preserves raw hidden tool events, partial recovery and explicit notices", () => { + const state = replay( + start, + tool, + { + type: "message", + id: "a", + role: "assistant", + parts: [], + metadata: { historySequence: 2, synthetic: true, partial: true }, + }, + tool, + toolEnd, + { + type: "stream-abort", + workspaceId: "w", + messageId: "a", + abortReason: "system", + } + ); + expect(getVisibleMessages(state.messages)).toEqual([]); + expect(state.messages[0].parts[0]).toMatchObject({ + toolCallId: "t", + input: tool.args, + output: toolEnd.result, + state: "output-available", + }); + expect(state.messages[0].metadata).toMatchObject({ partial: true, historySequence: 2 }); + expect(state.streaming).toBe(false); + const notice = applyChatEvent(state, { + ...state.messages[0], + type: "message", + metadata: { ...state.messages[0].metadata, uiVisible: true }, + }); + expect(getVisibleMessages(notice.messages)).toEqual(notice.messages); + const result = applyChatEvent(notice, { + ...notice.messages[0], + type: "message", + metadata: { + ...notice.messages[0].metadata, + muxMetadata: { type: "workflow-result", rawCommand: "/run", runId: "wfr_test" }, + }, + }); + expect(getVisibleMessages(result.messages)).toEqual([]); + expect( + getVisibleMessages( + replay(...result.messages.map((message) => ({ ...message, type: "message" as const }))) + .messages + ) + ).toEqual([]); + }); + test.each(["user", "system", "startup"] as const)( "records %s abort intent without suppressing involuntary recovery", (abortReason) => { diff --git a/packages/mobile/src/transcript.ts b/packages/mobile/src/transcript.ts index b6736dbe7e3..0171cabe402 100644 --- a/packages/mobile/src/transcript.ts +++ b/packages/mobile/src/transcript.ts @@ -1,3 +1,4 @@ +import { isWorkflowResultMessage } from "../../../src/common/utils/workflowRunMessages"; import { copyStreamMetadataSnapshot } from "../../../src/common/types/stream"; import type { WorkspaceChatMessage } from "../../../src/common/orpc/types"; import type { MuxMessage, MuxToolPart } from "../../../src/common/types/message"; @@ -13,6 +14,16 @@ export interface TranscriptState { hasOlderHistory: boolean; } +// Visibility is only a display projection: hidden rows still own pagination cursors, +// recovery metadata and tool events in the authoritative wire transcript. +export function getVisibleMessages(messages: MuxMessage[]): MuxMessage[] { + return messages.filter( + (message) => + (message.metadata?.synthetic !== true || message.metadata.uiVisible === true) && + !isWorkflowResultMessage(message) + ); +} + /** Reset before EVERY onChat({mode: {type: "full"}}), including reconnects. */ export function createTranscriptState(): TranscriptState { return { diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts index 5f7afdd4545..9287baee0f6 100644 --- a/packages/mobile/src/useConversation.test.ts +++ b/packages/mobile/src/useConversation.test.ts @@ -3,6 +3,7 @@ import { afterEach, expect, test } from "bun:test"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { createORPCClient } from "@orpc/client"; import type { MobileClient } from "./api"; +import { getVisibleMessages } from "./transcript"; import type { WorkspaceChatMessage } from "./transcript"; import { useConversation } from "./useConversation"; import type { RestoredInput } from "./draft"; @@ -215,7 +216,50 @@ test("restore events use the latest workspace callback once without resubscribin expect(replacement).toHaveLength(1); }); -test("older history is inserted without replacing newer copies and uses the oldest visible row", async () => { +test("hidden replay and all-hidden pages retain raw cursors and advance pagination", async () => { + const view = fixture(); + await view.ready(); + const hidden: WorkspaceChatMessage = { + type: "message", + id: "hidden", + role: "user", + parts: [], + metadata: { historySequence: 8, synthetic: true }, + }; + await view.emit(hidden); + const nextCursor = { beforeHistorySequence: 4, beforeMessageId: "older-hidden" }; + await act(async () => { + const pending = view.result.current.loadOlder(); + view.complete({ + messages: [ + { ...hidden, id: "older-hidden", metadata: { historySequence: 4, synthetic: true } }, + ], + nextCursor, + hasOlder: true, + }); + await pending; + }); + expect(view.requests[0].input).toEqual({ + workspaceId: "workspace", + cursor: { beforeHistorySequence: 8, beforeMessageId: "hidden" }, + }); + expect( + getVisibleMessages(view.result.current.transcript.messages).map((item) => item.id) + ).toEqual(["10"]); + expect(view.result.current.transcript.messages.map((item) => item.id)).toEqual([ + "older-hidden", + "hidden", + "10", + ]); + expect(view.result.current.transcript.caughtUp).toBe(true); + expect(view.result.current.transcript.hasOlderHistory).toBe(true); + await act(async () => view.result.current.loadOlder()); + expect(view.requests[1].input).toEqual({ workspaceId: "workspace", cursor: nextCursor }); + await view.emit({ type: "delete", historySequences: [4, 8] }); + expect(view.result.current.transcript.messages.map((item) => item.id)).toEqual(["10"]); +}); + +test("older history is inserted without replacing newer copies and uses the oldest wire row", async () => { const view = fixture(); await view.ready(); await act(async () => { From ffda71e2235c1b0e6d1aab9eb7242cc4b2608a94 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 02:56:29 +0000 Subject: [PATCH 65/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20render=20in?= =?UTF-8?q?spectable=20nested=20tool=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the existing Tool UI for canonical nestedCalls in parent order, with one bounded child indentation level. Live bridge starts, completed/error/redacted replay, and compact failed metadata retain accurate status and inspectable inputs/results. Nested questions remain inspection-only because the bridge excludes interactive tools. Validation: two missing-rendering regressions were red before the fix; live/open-inspector/replay and metadata/action tests plus a depth-bound regression pass. Full forms suite, mobile-check (145 pass, 1 existing live-server skip), root static-check, and final mobile typecheck/lint/format checks pass. The event fixture drives real parentToolCallId transcript reduction and verifies escaped hostile output. Scope: explicit persisted/live nestedCalls; legacy output.toolCalls-only reconstruction remains unchanged. No App/session/transcript/auth edits. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$159.56`_ --- packages/mobile/src/components/Message.tsx | 59 +++++- .../mobile/src/screens/forms.behavior.tsx | 200 ++++++++++++++++++ 2 files changed, 251 insertions(+), 8 deletions(-) diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index d2111bec0a4..a3ae23e6686 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { Brain, Check, ChevronDown, ChevronRight, File, Pause } from "lucide-react-native"; import type { MuxMessage, MuxToolPart } from "../../../../src/common/types/message"; +import type { NestedToolCall } from "../../../../src/common/orpc/schemas/message"; import type { AskUserQuestionQuestion, AskUserQuestionToolArgs, @@ -122,8 +123,16 @@ function toolHint(input: unknown): string | undefined { } } -function toolStatus(part: MuxToolPart, streaming: boolean, interrupted: boolean): string { - if (part.state === "output-redacted") return part.failed ? "Failed" : "Redacted"; +type ToolPart = MuxToolPart | NestedToolCall; + +function toolStatus( + part: ToolPart, + streaming: boolean, + interrupted: boolean, + nested: boolean +): string { + if ("failed" in part && part.failed) return "Failed"; + if (part.state === "output-redacted") return "Redacted"; if (part.state === "output-available") { return record(part.output) && (part.output.success === false || part.output.error) ? "Failed" @@ -131,7 +140,10 @@ function toolStatus(part: MuxToolPart, streaming: boolean, interrupted: boolean) } if (!streaming) return interrupted ? "Interrupted" : "No result"; if (part.toolName === "ask_user_question") return "Needs input"; - return part.executionStartedAt != null ? "Running" : "Pending"; + // Nested bridge start events mark execution directly; they have no separate start timestamp. + return nested || ("executionStartedAt" in part && part.executionStartedAt != null) + ? "Running" + : "Pending"; } const MAX_TOOL_CHARACTERS = 24_000; @@ -163,7 +175,8 @@ function ToolValue(props: { label: string; value: unknown }) { } function Tool(props: { - part: MuxToolPart; + part: ToolPart; + nested?: boolean; streaming: boolean; interrupted: boolean; canAnswer: boolean; @@ -171,19 +184,26 @@ function Tool(props: { }) { const [inspecting, setInspecting] = useState(false); const questionInput = - props.part.toolName === "ask_user_question" && props.part.state === "input-available" + !props.nested && + props.part.toolName === "ask_user_question" && + props.part.state === "input-available" ? AskUserQuestionToolArgsSchema.safeParse(props.part.input).data : undefined; const name = props.part.toolName .replaceAll("_", " ") .replace(/^./, (letter) => letter.toUpperCase()); const hint = toolHint(props.part.input); - const status = toolStatus(props.part, props.streaming, props.interrupted); + const status = toolStatus(props.part, props.streaming, props.interrupted, Boolean(props.nested)); // Live tools execute serially: queued questions are not registered for answers yet. // Recovered partials rely on the parent's eligibility check instead of an execution timestamp. - const waitingForExecution = props.streaming && props.part.executionStartedAt == null; + const waitingForExecution = + props.streaming && + (!("executionStartedAt" in props.part) || props.part.executionStartedAt == null); + // The bridge schema has one flat child level and excludes interactive questions. + const nestedCalls = + !props.nested && "nestedCalls" in props.part ? props.part.nestedCalls : undefined; return ( - + props.onAnswer(props.part.toolCallId, answers)} /> )} + {nestedCalls && nestedCalls.length > 0 && ( + + {nestedCalls.map((call) => ( + + ))} + + )} ); } @@ -390,6 +425,14 @@ const styles = StyleSheet.create({ borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: colors.border, }, + nestedTools: { + minWidth: 0, + marginLeft: spacing.sm, + paddingLeft: spacing.sm, + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: colors.border, + gap: spacing.sm, + }, toolName: { ...typography.footnote, color: colors.muted }, toolHint: { color: colors.text }, outputSurface: { backgroundColor: colors.panel, borderRadius: radii.control }, diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index ee0bf87be01..3929a423f4c 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -1185,6 +1185,206 @@ test.each([ expect(view.getByRole("button").querySelector(`svg[data-icon="${icon}"]`)).not.toBeNull(); }); +test("nested tool events render in parent order and update an open child inspector through replay", () => { + let transcript = applyChatEvent(createTranscriptState(), { + type: "stream-start", + workspaceId: "workspace", + messageId: "nested", + historySequence: 1, + startTime: 0, + model: "local:one", + }); + transcript = applyChatEvent(transcript, { + type: "tool-call-start", + workspaceId: "workspace", + messageId: "nested", + toolCallId: "parent", + toolName: "code_execution", + args: { code: "await xum.file_read({path:'notes.txt'})" }, + tokens: 1, + timestamp: 1, + executionStartedAt: 1, + }); + const renderMessage = () => ( + + { + throw new Error("Nested calls cannot answer"); + }} + /> + + ); + const view = render(renderMessage()); + for (const [toolCallId, toolName, args] of [ + ["read", "file_read", { path: "notes.txt" }], + ["shell", "bash", { script: "printf ok" }], + ] as const) + transcript = applyChatEvent(transcript, { + type: "tool-call-start", + workspaceId: "workspace", + messageId: "nested", + parentToolCallId: "parent", + toolCallId, + toolName, + args, + tokens: 0, + timestamp: 2, + }); + view.rerender(renderMessage()); + expect(view.getAllByRole("button").map((button) => button.getAttribute("aria-label"))).toEqual([ + "Code execution: Running", + "File read: Running. notes.txt", + "Bash: Running. printf ok", + ]); + const children = within(view.getByRole("group", { name: "Nested tool calls" })); + expect(children.getAllByRole("button")).toHaveLength(2); + expect( + children + .getByRole("button", { name: "File read: Running. notes.txt" }) + .querySelector('svg[data-icon="BookOpen"]') + ).not.toBeNull(); + fireEvent.click(children.getByRole("button", { name: "File read: Running. notes.txt" })); + const hostile = '' + " long-path/".repeat(50); + transcript = applyChatEvent(transcript, { + type: "tool-call-end", + workspaceId: "workspace", + messageId: "nested", + parentToolCallId: "parent", + toolCallId: "read", + toolName: "file_read", + result: hostile, + timestamp: 3, + }); + view.rerender(renderMessage()); + expect(view.getByText(hostile)).toBeDefined(); + expect(document.querySelector("img")).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Close" })); + transcript = applyChatEvent(transcript, { + type: "tool-call-end", + workspaceId: "workspace", + messageId: "nested", + parentToolCallId: "parent", + toolCallId: "shell", + toolName: "bash", + result: { error: "Command failed" }, + timestamp: 4, + }); + transcript = applyChatEvent(transcript, { + type: "tool-call-end", + workspaceId: "workspace", + messageId: "nested", + toolCallId: "parent", + toolName: "code_execution", + result: { success: true, result: "Wrapper result" }, + timestamp: 5, + }); + view.unmount(); + const replay = render( + + {}} + /> + + ); + expect(replay.getAllByRole("button").map((button) => button.getAttribute("aria-label"))).toEqual([ + "Code execution: Done", + "File read: Done. notes.txt", + "Bash: Failed. printf ok", + ]); + fireEvent.click(replay.getByRole("button", { name: "Bash: Failed. printf ok" })); + expect(replay.getByText(/Command failed/)).toBeDefined(); + fireEvent.click(replay.getByRole("button", { name: "Close" })); + fireEvent.click(replay.getByRole("button", { name: "Code execution: Done" })); + expect(replay.getByText(/Wrapper result/)).toBeDefined(); +}); + +test("nested replay preserves failure/redaction/interruption metadata and never offers child questions", () => { + const question = prefilledQuestionPart({ "Which branch?": "main" }); + let answers = 0; + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "wrapper", + toolName: "code_execution", + input: {}, + state: "input-available", + nestedCalls: [ + { + toolCallId: "redacted", + toolName: "bash", + state: "output-redacted", + output: "must stay hidden", + }, + { toolCallId: "failed", toolName: "file_read", state: "output-available", failed: true }, + { + toolCallId: "pending", + toolName: "web_fetch", + input: { url: "https://example.test" }, + state: "input-available", + }, + { + toolCallId: "question", + toolName: "ask_user_question", + input: question.input, + state: "input-available", + }, + ], + }; + const view = render( + { + answers++; + }} + /> + ); + expect(view.getByRole("button", { name: "Code execution: Interrupted" })).toBeDefined(); + expect(view.getByRole("button", { name: "File read: Failed" })).toBeDefined(); + expect(view.getByRole("button", { name: "Web fetch: Interrupted" })).toBeDefined(); + fireEvent.click(view.getByRole("button", { name: "Bash: Redacted" })); + expect(view.queryByText("must stay hidden")).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Close" })); + fireEvent.click(view.getByRole("button", { name: "Ask user question: Interrupted" })); + expect(view.getByText(/Which branch/)).toBeDefined(); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); + expect(view.queryByRole("radio")).toBeNull(); + expect(answers).toBe(0); +}); + +test("nested rows stay at the supported child depth instead of recursively consuming narrow width", () => { + const child = { + toolCallId: "child", + toolName: "file_read", + state: "output-available" as const, + output: "Child result", + nestedCalls: [{ toolCallId: "unsupported-depth", toolName: "bash", state: "input-available" }], + }; + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "parent", + toolName: "code_execution", + input: {}, + state: "output-available", + output: {}, + nestedCalls: [child], + }; + const view = render( + + {}} /> + + ); + expect(view.getAllByRole("group", { name: "Nested tool calls" })).toHaveLength(1); + expect(view.getAllByRole("button")).toHaveLength(2); + fireEvent.click(view.getByRole("button", { name: "File read: Done" })); + expect(view.getByText("Child result")).toBeDefined(); + expect(view.queryByRole("button", { name: /Bash/ })).toBeNull(); +}); + test("tool headers distinguish execution, completion, failure, redaction, and interrupted replay", () => { const part: MuxToolPart = { type: "dynamic-tool", From 6e61688c09ca2b064af12c47c856f75d3625f664 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 03:04:48 +0000 Subject: [PATCH 66/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20share=20leg?= =?UTF-8?q?acy=20nested=20tool=20replay=20reconstruction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the existing desktop code_execution result.toolCalls parser into one React-free common helper and reuse it from both renderers. Preserve explicit nestedCalls precedence including empty arrays, compact failure metadata, malformed-record filtering, and existing stable legacy identities. Mobile legacy child rows remain flat and inspection-only. Validation: legacy mobile replay test red before extraction and green afterward; three shared-helper tests, two unchanged desktop reconstruction tests, six desktop nested aggregator tests, and all mobile forms pass. Full mobile-check passes (145 tests, 1 existing live-server skip), and root static-check including types/lint/format passes. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$186.92`_ --- packages/mobile/src/components/Message.tsx | 3 +- .../mobile/src/screens/forms.behavior.tsx | 67 +++++++++++++++++++ .../utils/messages/displayedMessageBuilder.ts | 62 +---------------- .../utils/messages/nestedToolCalls.test.ts | 51 ++++++++++++++ src/common/utils/messages/nestedToolCalls.ts | 63 +++++++++++++++++ 5 files changed, 184 insertions(+), 62 deletions(-) create mode 100644 src/common/utils/messages/nestedToolCalls.test.ts create mode 100644 src/common/utils/messages/nestedToolCalls.ts diff --git a/packages/mobile/src/components/Message.tsx b/packages/mobile/src/components/Message.tsx index a3ae23e6686..38cf93661eb 100644 --- a/packages/mobile/src/components/Message.tsx +++ b/packages/mobile/src/components/Message.tsx @@ -13,6 +13,7 @@ import { Button, Field, Notice, Sheet } from "./Controls"; import { Markdown } from "./Markdown"; import { ToolIcon } from "./ToolIcon"; import { mergeAdjacentParts } from "../../../../src/common/utils/messages/mergeAdjacentParts"; +import { getNestedCallsForDisplay } from "../../../../src/common/utils/messages/nestedToolCalls"; import { colors, layout, mono, radii, spacing, typography } from "../theme"; export function Message(props: { @@ -201,7 +202,7 @@ function Tool(props: { (!("executionStartedAt" in props.part) || props.part.executionStartedAt == null); // The bridge schema has one flat child level and excludes interactive questions. const nestedCalls = - !props.nested && "nestedCalls" in props.part ? props.part.nestedCalls : undefined; + !props.nested && "type" in props.part ? getNestedCallsForDisplay(props.part) : undefined; return ( { + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "legacy", + toolName: "code_execution", + input: { code: "legacy execution" }, + state: "output-available", + output: { + success: true, + toolCalls: [ + { + toolName: "file_read", + args: { path: "legacy.txt" }, + result: "Retained result", + duration_ms: 1, + }, + { toolName: "bash", error: "Legacy command failed", duration_ms: 2 }, + { toolName: "web_fetch", ok: false, duration_ms: 3 }, + { + toolName: "ask_user_question", + args: prefilledQuestionPart({ "Which branch?": "main" }).input, + duration_ms: 4, + }, + ], + }, + }; + const renderMessage = (value: MuxToolPart) => ( + { + throw new Error("Legacy child actions must not run"); + }} + /> + ); + const view = render(renderMessage(part)); + fireEvent.click(view.getByRole("button", { name: "File read: Done. legacy.txt" })); + expect(view.getByText("Retained result")).toBeDefined(); + fireEvent.click(view.getByRole("button", { name: "Close" })); + fireEvent.click(view.getByRole("button", { name: "Bash: Failed" })); + expect(view.getByText(/Legacy command failed/)).toBeDefined(); + fireEvent.click(view.getByRole("button", { name: "Close" })); + expect(view.getByRole("button", { name: "Web fetch: Failed" })).toBeDefined(); + expect(view.getByRole("button", { name: "Ask user question: Done" })).toBeDefined(); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); + view.rerender(renderMessage({ ...part, nestedCalls: [] })); + expect(view.queryByRole("group", { name: "Nested tool calls" })).toBeNull(); + expect(view.getAllByRole("button")).toHaveLength(1); + view.rerender( + renderMessage({ + ...part, + nestedCalls: [ + { + toolCallId: "explicit", + toolName: "file_read", + input: { path: "current.txt" }, + output: "Current result", + state: "output-available", + }, + ], + }) + ); + expect(view.queryByRole("button", { name: "File read: Done. legacy.txt" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "File read: Done. current.txt" })); + expect(view.getByText("Current result")).toBeDefined(); +}); + test("tool headers distinguish execution, completion, failure, redaction, and interrupted replay", () => { const part: MuxToolPart = { type: "dynamic-tool", diff --git a/src/browser/utils/messages/displayedMessageBuilder.ts b/src/browser/utils/messages/displayedMessageBuilder.ts index 1e395a0564d..ba94cb9b391 100644 --- a/src/browser/utils/messages/displayedMessageBuilder.ts +++ b/src/browser/utils/messages/displayedMessageBuilder.ts @@ -32,6 +32,7 @@ import { isPlainObject } from "@/common/utils/isPlainObject"; import { isRefusalFinishReason } from "@/common/utils/messages/refusalFinishReason"; import { isDynamicToolPart, type DynamicToolPart } from "@/common/types/toolParts"; import { mergeAdjacentParts } from "@/common/utils/messages/mergeAdjacentParts"; +import { getNestedCallsForDisplay } from "@/common/utils/messages/nestedToolCalls"; export { mergeAdjacentParts } from "@/common/utils/messages/mergeAdjacentParts"; @@ -133,7 +134,6 @@ export interface BuildDisplayedMessagesForMessageOptions { } type ToolDisplayStatus = Extract["status"]; -type NestedToolCalls = NonNullable; function buildPlanDisplayMessages( message: MuxMessage, @@ -490,66 +490,6 @@ function getToolDisplayStatus(part: DynamicToolPart, isPartial: boolean): ToolDi return "pending"; } -function getObjectField(value: unknown, field: string): unknown { - return typeof value === "object" && value !== null - ? (value as Record)[field] - : undefined; -} - -function reconstructCodeExecutionNestedCalls(part: DynamicToolPart): NestedToolCalls | undefined { - if (part.toolName !== "code_execution" || part.state !== "output-available") { - return undefined; - } - - const toolCalls = getObjectField(part.output, "toolCalls"); - if (!Array.isArray(toolCalls)) { - return undefined; - } - - const nestedCalls: NestedToolCalls = []; - for (const [idx, toolCall] of toolCalls.entries()) { - if (typeof toolCall !== "object" || toolCall === null) { - continue; - } - const record = toolCall as Record; - if (typeof record.toolName !== "string" || typeof record.duration_ms !== "number") { - continue; - } - - const output = - record.result ?? - (typeof record.error === "string" - ? // success:false matches the failure shape tool cards and - // isFailedToolOutput already understand, so the error stays - // visible (e.g. bash's ErrorBox) after reload. - { success: false, error: record.error } - : undefined); - // RLM kernel-mode compact record (r12): the full nested result never - // persists in the tool output, so degraded detail after reload is expected - // (live streaming keeps full detail via part.nestedCalls, which takes - // precedence). Failure travels out-of-band via `failed` instead of a - // synthetic output shape, so a real tool result can never be mistaken - // for a reconstruction stand-in. - const kernelFailure = output === undefined && record.ok === false; - - nestedCalls.push({ - toolCallId: `${part.toolCallId}-nested-${idx}`, - toolName: record.toolName, - input: record.args, - output, - ...(kernelFailure ? { failed: true } : {}), - state: "output-available", - timestamp: part.timestamp, - }); - } - - return nestedCalls.length > 0 ? nestedCalls : undefined; -} - -function getNestedCallsForDisplay(part: DynamicToolPart): NestedToolCalls | undefined { - return part.nestedCalls ?? reconstructCodeExecutionNestedCalls(part); -} - function appendToolRows( displayedMessages: DisplayedMessage[], options: { diff --git a/src/common/utils/messages/nestedToolCalls.test.ts b/src/common/utils/messages/nestedToolCalls.test.ts new file mode 100644 index 00000000000..9da34cfce9f --- /dev/null +++ b/src/common/utils/messages/nestedToolCalls.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import type { MuxToolPart } from "@/common/types/message"; +import { getNestedCallsForDisplay } from "./nestedToolCalls"; + +const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "parent", + toolName: "code_execution", + input: {}, + state: "output-available", + output: { toolCalls: [{ toolName: "bash", result: "legacy", duration_ms: 1 }] }, +}; + +test("explicit nested arrays retain precedence and identity, including empty arrays", () => { + for (const nestedCalls of [ + [], + [{ toolCallId: "live", toolName: "file_read", state: "input-available" as const }], + ]) { + expect(getNestedCallsForDisplay({ ...part, nestedCalls })).toBe(nestedCalls); + } + expect(getNestedCallsForDisplay(part)?.[0]?.output).toBe("legacy"); +}); + +test("legacy records are reconstructed only for completed code_execution output", () => { + expect(getNestedCallsForDisplay({ ...part, toolName: "bash" })).toBeUndefined(); + expect(getNestedCallsForDisplay({ ...part, state: "input-available" })).toBeUndefined(); + expect(getNestedCallsForDisplay({ ...part, state: "output-redacted" })).toBeUndefined(); + expect(getNestedCallsForDisplay({ ...part, output: { toolCalls: "invalid" } })).toBeUndefined(); +}); + +test("malformed records are skipped while falsy results and original ordering survive", () => { + const calls = getNestedCallsForDisplay({ + ...part, + output: { + toolCalls: [ + null, + { toolName: "invalid", duration_ms: "one" }, + { + toolName: "bash", + result: false, + error: "must not replace a real result", + duration_ms: 2, + }, + { toolName: "file_read", result: 0, duration_ms: 3 }, + ], + }, + }); + expect(calls?.map((call) => call.toolName)).toEqual(["bash", "file_read"]); + expect(calls?.map((call) => call.output)).toEqual([false, 0]); + expect(calls?.map((call) => call.toolCallId)).toEqual(["parent-nested-2", "parent-nested-3"]); +}); diff --git a/src/common/utils/messages/nestedToolCalls.ts b/src/common/utils/messages/nestedToolCalls.ts new file mode 100644 index 00000000000..f23238a7f89 --- /dev/null +++ b/src/common/utils/messages/nestedToolCalls.ts @@ -0,0 +1,63 @@ +import type { DynamicToolPart } from "@/common/types/toolParts"; + +type NestedToolCalls = NonNullable; + +function getObjectField(value: unknown, field: string): unknown { + return typeof value === "object" && value !== null + ? (value as Record)[field] + : undefined; +} + +function reconstructCodeExecutionNestedCalls(part: DynamicToolPart): NestedToolCalls | undefined { + if (part.toolName !== "code_execution" || part.state !== "output-available") { + return undefined; + } + + const toolCalls = getObjectField(part.output, "toolCalls"); + if (!Array.isArray(toolCalls)) { + return undefined; + } + + const nestedCalls: NestedToolCalls = []; + for (const [idx, toolCall] of toolCalls.entries()) { + if (typeof toolCall !== "object" || toolCall === null) { + continue; + } + const record = toolCall as Record; + if (typeof record.toolName !== "string" || typeof record.duration_ms !== "number") { + continue; + } + + const output = + record.result ?? + (typeof record.error === "string" + ? // success:false matches the failure shape tool cards and + // isFailedToolOutput already understand, so the error stays + // visible (e.g. bash's ErrorBox) after reload. + { success: false, error: record.error } + : undefined); + // RLM kernel-mode compact record (r12): the full nested result never + // persists in the tool output, so degraded detail after reload is expected + // (live streaming keeps full detail via part.nestedCalls, which takes + // precedence). Failure travels out-of-band via `failed` instead of a + // synthetic output shape, so a real tool result can never be mistaken + // for a reconstruction stand-in. + const kernelFailure = output === undefined && record.ok === false; + + nestedCalls.push({ + toolCallId: `${part.toolCallId}-nested-${idx}`, + toolName: record.toolName, + input: record.args, + output, + ...(kernelFailure ? { failed: true } : {}), + state: "output-available", + timestamp: part.timestamp, + }); + } + + return nestedCalls.length > 0 ? nestedCalls : undefined; +} + +export function getNestedCallsForDisplay(part: DynamicToolPart): NestedToolCalls | undefined { + return part.nestedCalls ?? reconstructCodeExecutionNestedCalls(part); +} From 19639e52322a59b91a1348d97fd05ca564e3a0c2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 03:09:03 +0000 Subject: [PATCH 67/84] =?UTF-8?q?=F0=9F=A4=96=20fix(auth):=20authorize=20o?= =?UTF-8?q?RPC=20WebSockets=20with=20single-use=20upgrade=20tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add master-bearer-only HTTP ticket issuance, a bounded per-server 30-second ticket store, stable-protocol negotiation, and synchronous single-use consumption before connection authorization. Preserve cookie revocation and legacy clients; remove credentials from rejected-origin URL logs. Validation: 89 targeted ticket/server/router/schema tests passed (3 optional timing tests skipped), 4 service-context tests passed, and root static-check passed with Bun 1.3.5. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$119.38`_ --- src/common/constants/webSocketAuth.ts | 6 + src/common/orpc/schemas/api.ts | 10 + src/common/orpc/types.ts | 1 + src/node/orpc/authMiddleware.ts | 12 +- src/node/orpc/context.ts | 3 + src/node/orpc/router.ts | 22 +- src/node/orpc/server.ts | 66 ++++- .../orpc/webSocketTickets.integration.test.ts | 251 ++++++++++++++++++ src/node/orpc/webSocketTickets.test.ts | 64 +++++ src/node/orpc/webSocketTickets.ts | 76 ++++++ src/node/services/serviceContainer.test.ts | 4 +- 11 files changed, 505 insertions(+), 10 deletions(-) create mode 100644 src/common/constants/webSocketAuth.ts create mode 100644 src/node/orpc/webSocketTickets.integration.test.ts create mode 100644 src/node/orpc/webSocketTickets.test.ts create mode 100644 src/node/orpc/webSocketTickets.ts diff --git a/src/common/constants/webSocketAuth.ts b/src/common/constants/webSocketAuth.ts new file mode 100644 index 00000000000..388b5ceb0e0 --- /dev/null +++ b/src/common/constants/webSocketAuth.ts @@ -0,0 +1,6 @@ +/** General oRPC upgrade credentials: never put the server bearer in the URL. */ +export const ORPC_WS_PROTOCOL = "xum.orpc.v1"; +export const ORPC_WS_TICKET_PREFIX = "xum.orpc.ticket.v1."; +export const ORPC_WS_TICKET_TTL_MS = 30_000; +export const ORPC_WS_TICKET_MAX_PENDING = 256; +export const ORPC_WS_TICKET_PATTERN = /^[a-f0-9]{64}$/; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 12ea804e830..129c8df22b3 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1,3 +1,4 @@ +import { ORPC_WS_TICKET_PATTERN } from "@/common/constants/webSocketAuth"; import { ClaudeDesignSettingsSchema, ClaudeDesignStatusSchema, @@ -2499,6 +2500,15 @@ export const server = { }; export const serverAuth = { + issueWebSocketTicket: { + input: z.void(), + output: z + .object({ + ticket: z.string().regex(ORPC_WS_TICKET_PATTERN), + expiresAtMs: z.number().int().nonnegative(), + }) + .strict(), + }, listSessions: { input: z.void(), output: z.array(ServerAuthSessionSchema), diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index f9b338edc23..254270d129d 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -63,6 +63,7 @@ export type FrontendWorkspaceMetadataSchemaType = z.infer< // Server types (single source of truth - derived from schemas) export type ApiServerStatus = z.infer; export type ServerAuthSession = z.infer; +export type WebSocketTicket = z.infer; // Experiment types (single source of truth - derived from schemas) // Policy types (single source of truth - derived from schemas) diff --git a/src/node/orpc/authMiddleware.ts b/src/node/orpc/authMiddleware.ts index 493af6e90eb..859012eff1d 100644 --- a/src/node/orpc/authMiddleware.ts +++ b/src/node/orpc/authMiddleware.ts @@ -29,7 +29,7 @@ export function safeEq(a: string, b: string): boolean { return bytesMatch && bufA.length === bufB.length; } -function extractBearerToken(header: string | string[] | undefined): string | null { +export function extractBearerToken(header: string | string[] | undefined): string | null { const h = Array.isArray(header) ? header[0] : header; if (!h?.toLowerCase().startsWith("bearer ")) return null; return h.slice(7).trim() || null; @@ -110,6 +110,14 @@ export function extractCookieValues( return tokens; } +// Object identity, not a request header value, grants a redeemed master ticket's +// authority to exactly its connection. HTTP/cookie contexts cannot manufacture it. +const ticketAuthorizedHeaders = new WeakSet(); + +export function authorizeWebSocketTicketHeaders(headers: IncomingHttpHeaders): void { + ticketAuthorizedHeaders.add(headers); +} + /** Create auth middleware that validates Authorization header or session cookie from context */ export function createAuthMiddleware(authToken?: string) { // oRPC >=1.14 no longer accepts a union of differently-typed middlewares in @@ -125,7 +133,7 @@ export function createAuthMiddleware(authToken?: string) { }, }) .middleware(async ({ context, errors, next }) => { - if (!expectedToken) { + if (!expectedToken || (context.headers && ticketAuthorizedHeaders.has(context.headers))) { return next(); } diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 68877d86595..67f7e9d38c3 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -1,3 +1,4 @@ +import type { WebSocketTicket } from "@/common/orpc/types"; import type { IJSRuntimeFactory } from "@/node/services/ptc/runtime"; import type { IncomingHttpHeaders } from "http"; import type { @@ -126,5 +127,7 @@ export interface ORPCContext extends WithEffectContext { desktopTokenManager: DesktopTokenManager; desktopBridgeServer: DesktopBridgeServer; workflowRuntimeFactory: IJSRuntimeFactory; + /** Supplied only by this server's HTTP POST handler, never by WS/IPC or input. */ + issueWebSocketTicket?: () => WebSocketTicket; headers?: IncomingHttpHeaders; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 9119ddb72e9..3e7ceac7336 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -18,7 +18,7 @@ import { EXPERIMENT_IDS } from "@/common/constants/experiments"; * uninterruptible in the service pipeline (see asAtomicMutation in * providerService.ts and startDesktopFlowEffect in muxGatewayOauthService.ts). */ -import { os } from "@orpc/server"; +import { ORPCError, os } from "@orpc/server"; import * as schemas from "@/common/orpc/schemas"; import type { ORPCContext } from "./context"; import { @@ -84,6 +84,8 @@ import { generateWorkspaceIdentity } from "@/node/services/workspaceTitleGenerat import { createAuthMiddleware, + extractBearerToken, + safeEq, extractClientIpAddress, extractCookieValues, getFirstHeaderValue, @@ -252,6 +254,24 @@ export const router = (authToken?: string) => { .handler(({ context, input }) => setApiServerSettings(context, input)), }, serverAuth: { + issueWebSocketTicket: t + .input(schemas.serverAuth.issueWebSocketTicket.input) + .output(schemas.serverAuth.issueWebSocketTicket.output) + .handler(({ context }) => { + const presented = extractBearerToken(context.headers?.authorization); + // Normal auth also accepts cookies. Never promote that revocable identity + // (or an invalid bearer alongside it) into a master-authority WS ticket. + if ( + !authToken?.trim() || + !presented || + !safeEq(presented, authToken.trim()) || + !context.issueWebSocketTicket + ) + throw new ORPCError("UNAUTHORIZED", { + message: "WebSocket ticket requires master bearer authentication over HTTP POST", + }); + return context.issueWebSocketTicket(); + }), listSessions: t .input(schemas.serverAuth.listSessions.input) .output(schemas.serverAuth.listSessions.output) diff --git a/src/node/orpc/server.ts b/src/node/orpc/server.ts index e4a56adf02e..197bbbf6345 100644 --- a/src/node/orpc/server.ts +++ b/src/node/orpc/server.ts @@ -31,7 +31,14 @@ export function createOpenAPIGenerator(): OpenAPIGenerator { } import { router, type AppRouter } from "@/node/orpc/router"; import type { ORPCContext } from "@/node/orpc/context"; -import { extractCookieValues, extractWsHeaders, safeEq } from "@/node/orpc/authMiddleware"; +import { + authorizeWebSocketTicketHeaders, + extractCookieValues, + extractWsHeaders, + safeEq, +} from "@/node/orpc/authMiddleware"; +import { ORPC_WS_PROTOCOL } from "@/common/constants/webSocketAuth"; +import { parseWebSocketTicketProtocols, WebSocketTicketStore } from "./webSocketTickets"; import { VERSION } from "@/version"; import { formatOrpcError } from "@/node/orpc/formatOrpcError"; import { BROWSER_BRIDGE_WS_PATH, DESKTOP_WS_PATH, ORPC_WS_PATH } from "@/node/orpc/wsPaths"; @@ -787,6 +794,10 @@ export async function createOrpcServer({ desktopBridgeServer = context.desktopBridgeServer, browserBridgeServer = context.browserBridgeServer, }: OrpcServerOptions): Promise { + // authToken/router authentication is immutable for this server lifetime. Closing + // the server destroys its ticket audience; no credential epoch or disk state is needed. + const webSocketTickets = new WebSocketTicketStore(); + const ticketAuthenticatedRequests = new WeakSet(); // Express app setup const app = express(); app.use((req, res, next) => { @@ -1559,7 +1570,22 @@ export async function createOrpcServer({ app.use("/orpc", async (req, res, next) => { const { matched } = await orpcHandler.handle(req, res, { prefix: getDirectAppProxyHandlerPrefix(req, "/orpc"), - context: { ...context, headers: req.headers }, + context: { + ...context, + headers: req.headers, + issueWebSocketTicket: + req.method === "POST" + ? () => { + res.setHeader("Cache-Control", "no-store"); + const issued = webSocketTickets.mint(); + if (!issued) + throw new ORPCError("TOO_MANY_REQUESTS", { + message: "WebSocket ticket capacity unavailable", + }); + return issued; + } + : undefined, + }, }); if (matched) return; next(); @@ -1621,7 +1647,13 @@ export async function createOrpcServer({ }); // oRPC WebSocket handler - const wsServer = new WebSocketServer({ noServer: true }); + const wsServer = new WebSocketServer({ + noServer: true, + handleProtocols: (protocols, req) => + parseWebSocketTicketProtocols(req.headers["sec-websocket-protocol"]).type === "ticket" + ? ORPC_WS_PROTOCOL + : (protocols.values().next().value ?? false), + }); httpServer.on("upgrade", (req, socket, head) => { const normalizedRoute = getNormalizedUpgradeRoute(req.url); @@ -1638,7 +1670,7 @@ export async function createOrpcServer({ log.warn("Blocked cross-origin WebSocket upgrade request", { origin: getFirstHeaderValue(req, "origin"), expectedOrigins, - url: req.url, + url: routePathname, }); try { @@ -1650,7 +1682,27 @@ export async function createOrpcServer({ return; } + const ticket = parseWebSocketTicketProtocols(req.headers["sec-websocket-protocol"]); + if ( + ticket.type === "invalid" || + (ticket.type === "ticket" && !webSocketTickets.isValid(ticket.ticket)) + ) { + socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n", () => + socket.destroy() + ); + return; + } wsServer.handleUpgrade(req, socket, head, (ws) => { + // No awaits: competing successful peeks must yield only one authorized + // consumer, before any connection listener or oRPC context can observe it. + if (ticket.type === "ticket") { + if (!webSocketTickets.consume(ticket.ticket)) { + ws.terminate(); + return; + } + ticketAuthenticatedRequests.add(req); + req.headers["sec-websocket-protocol"] = ORPC_WS_PROTOCOL; + } wsServer.emit("connection", ws, req); }); return; @@ -1729,12 +1781,15 @@ export async function createOrpcServer({ socket.isAlive = true; }); - const headers = extractWsHeaders(req); + const ticketAuthenticated = ticketAuthenticatedRequests.has(req); + const headers = ticketAuthenticated ? { ...req.headers } : extractWsHeaders(req); + if (ticketAuthenticated) authorizeWebSocketTicketHeaders(headers); // Use Object.defineProperties to copy all property descriptors from // the base context as own-properties (required by oRPC's internal // property enumeration) while preserving any lazily-resolving getters. const wsContext = Object.defineProperties({} as typeof context, { ...Object.getOwnPropertyDescriptors(context), + issueWebSocketTicket: { value: undefined, enumerable: true, configurable: true }, headers: { value: headers, enumerable: true, @@ -1780,6 +1835,7 @@ export async function createOrpcServer({ specUrl: `http://${connectableHostForUrl}:${actualPort}/api/spec.json`, docsUrl: `http://${connectableHostForUrl}:${actualPort}/api/docs`, close: async () => { + webSocketTickets.dispose(); clearInterval(heartbeatInterval); for (const ws of wsServer.clients) { ws.terminate(); diff --git a/src/node/orpc/webSocketTickets.integration.test.ts b/src/node/orpc/webSocketTickets.integration.test.ts new file mode 100644 index 00000000000..f120cd75ec4 --- /dev/null +++ b/src/node/orpc/webSocketTickets.integration.test.ts @@ -0,0 +1,251 @@ +import { afterEach, expect, mock, spyOn, test } from "bun:test"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink as HTTPRPCLink } from "@orpc/client/fetch"; +import { RPCLink as WebSocketRPCLink } from "@orpc/client/websocket"; +import { createRouterClient, type RouterClient } from "@orpc/server"; +import { WebSocket } from "ws"; +import type { ORPCContext } from "./context"; +import { router, type AppRouter } from "./router"; +import { authorizeWebSocketTicketHeaders } from "./authMiddleware"; +import { createOrpcServer } from "./server"; +import { log } from "@/node/services/log"; +import { + ORPC_WS_PROTOCOL, + ORPC_WS_TICKET_PREFIX, + ORPC_WS_TICKET_TTL_MS, +} from "@/common/constants/webSocketAuth"; + +const MASTER = "private token/+?"; +const COOKIE = "mux_session=cookie-session"; +afterEach(() => mock.restore()); + +async function fixture(authToken: string | undefined = MASTER) { + let sessionValid = true; + const server = await createOrpcServer({ + host: "127.0.0.1", + port: 0, + authToken, + context: { + serverService: { getSshHost: () => "authenticated", isShuttingDown: () => false }, + serverAuthService: { + validateSessionToken: () => Promise.resolve(sessionValid ? { sessionId: "session" } : null), + }, + } as unknown as ORPCContext, + }); + return { + ...server, + revokeSession() { + sessionValid = false; + }, + client( + headers: Record = { Authorization: `Bearer ${MASTER}` }, + prefix: "" | `/${string}` = "" + ) { + return createORPCClient>( + new HTTPRPCLink({ origin: server.baseUrl, url: `${prefix}/orpc`, headers }) + ); + }, + [Symbol.asyncDispose]: () => server.close(), + }; +} + +async function connect(url: string, protocols?: string[], headers?: Record) { + const ws = new WebSocket(url, protocols, { headers }); + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + ws.once("close", () => reject(new Error("Connection closed"))); + }); + return { + ws, + client: createORPCClient>( + new WebSocketRPCLink({ connect: () => ws, reconnect: { enabled: false } }) + ), + }; +} +async function expectRejected(request: Promise, code?: string): Promise { + try { + await request; + } catch (error) { + if (code) expect(error).toMatchObject({ code }); + return; + } + throw new Error("Expected request rejection"); +} + +function protocols(ticket: string) { + return [ORPC_WS_PROTOCOL, `${ORPC_WS_TICKET_PREFIX}${ticket}`]; +} + +test("authenticated HTTP mint authorizes one clean-URL connection and negotiates no secret", async () => { + await using server = await fixture(); + const issued = await server.client().serverAuth.issueWebSocketTicket(); + expect(issued.expiresAtMs).toBeGreaterThan(Date.now()); + const connection = await connect(server.wsUrl, protocols(issued.ticket)); + expect(connection.ws.protocol).toBe(ORPC_WS_PROTOCOL); + expect(await connection.client.server.getSshHost()).toBe("authenticated"); + await expectRejected(connection.client.serverAuth.issueWebSocketTicket(), "UNAUTHORIZED"); + await expectRejected(connect(server.wsUrl, protocols(issued.ticket))); + expect(await connection.client.server.getSshHost()).toBe("authenticated"); +}); + +test("cookie identity cannot mint master tickets and stays subject to revocation", async () => { + await using server = await fixture(); + const credentials: Array> = [ + {}, + { Cookie: COOKIE }, + { Authorization: "Bearer wrong", Cookie: COOKIE }, + ]; + for (const headers of credentials) { + await expectRejected(server.client(headers).serverAuth.issueWebSocketTicket(), "UNAUTHORIZED"); + } + const cookie = await connect(server.wsUrl, undefined, { Cookie: COOKIE }); + expect(await cookie.client.server.getSshHost()).toBe("authenticated"); + server.revokeSession(); + await expectRejected(cookie.client.server.getSshHost(), "UNAUTHORIZED"); +}); + +test("auth-disabled servers cannot mint a master ticket", async () => { + await using server = await fixture(""); + await expectRejected(server.client().serverAuth.issueWebSocketTicket(), "UNAUTHORIZED"); +}); + +test("unknown, malformed and expired new-format tickets never fall back to ambient auth", async () => { + await using server = await fixture(); + const issued = await server.client().serverAuth.issueWebSocketTicket(); + const headers = { Authorization: `Bearer ${MASTER}`, Cookie: COOKIE }; + for (const offered of [ + protocols("0".repeat(64)), + protocols("bad"), + [ORPC_WS_PROTOCOL], + protocols(issued.ticket).reverse(), + ]) { + await expectRejected( + connect(`${server.wsUrl}?token=${encodeURIComponent(MASTER)}`, offered, headers) + ); + } + const now = Date.now(); + const clock = spyOn(Date, "now").mockReturnValue(now + ORPC_WS_TICKET_TTL_MS); + await expectRejected(connect(server.wsUrl, protocols(issued.ticket), headers)); + clock.mockRestore(); +}); + +test("origin, path and server audience rejections do not consume a valid ticket", async () => { + await using server = await fixture(); + await using other = await fixture(); + const issued = await server.client().serverAuth.issueWebSocketTicket(); + await expectRejected( + connect(server.wsUrl, protocols(issued.ticket), { Origin: "https://evil.example" }) + ); + await expectRejected( + connect(server.wsUrl.replace("/orpc/ws", "/other"), protocols(issued.ticket)) + ); + await expectRejected(connect(other.wsUrl, protocols(issued.ticket))); + const valid = await connect(server.wsUrl, protocols(issued.ticket)); + expect(await valid.client.server.getSshHost()).toBe("authenticated"); +}); + +test("legacy Authorization, query and raw subprotocol clients retain their authentication", async () => { + await using server = await fixture("legacy-token"); + for (const input of [ + { url: server.wsUrl, offered: undefined, headers: { Authorization: "Bearer legacy-token" } }, + { url: `${server.wsUrl}?token=legacy-token`, offered: undefined, headers: undefined }, + { url: server.wsUrl, offered: ["legacy-token", "other"], headers: undefined }, + ]) { + const legacy = await connect(input.url, input.offered, input.headers); + expect(legacy.ws.protocol).toBe(input.offered?.[0] ?? ""); + expect(await legacy.client.server.getSshHost()).toBe("authenticated"); + } +}); + +test("app-proxy HTTP issuance and WebSocket redemption preserve prefix support", async () => { + await using server = await fixture(); + const prefix = "/@owner/workspace/apps/xum"; + const issued = await server.client(undefined, prefix).serverAuth.issueWebSocketTicket(); + const connection = await connect( + server.wsUrl.replace("/orpc/ws", `${prefix}/orpc/ws`), + protocols(issued.ticket) + ); + expect(await connection.client.server.getSshHost()).toBe("authenticated"); +}); + +test("rejected-origin logs contain neither legacy query credentials nor ticket protocols", async () => { + await using server = await fixture(); + const warn = spyOn(log, "warn").mockImplementation(() => undefined); + const issued = await server.client().serverAuth.issueWebSocketTicket(); + await expectRejected( + connect(`${server.wsUrl}?token=${encodeURIComponent(MASTER)}`, protocols(issued.ticket), { + Origin: "https://evil.example", + }) + ); + const logged = JSON.stringify(warn.mock.calls); + expect(logged).not.toContain(MASTER); + expect(logged).not.toContain(encodeURIComponent(MASTER)); + expect(logged).not.toContain(issued.ticket); + expect(warn).toHaveBeenCalled(); +}); + +test("ticket issuance requires HTTP POST and is never cacheable", async () => { + await using server = await fixture(); + const endpoint = `${server.baseUrl}/orpc/serverAuth/issueWebSocketTicket`; + const headers = { Authorization: `Bearer ${MASTER}`, "Content-Type": "application/json" }; + const response = await fetch(endpoint, { method: "POST", headers, body: "{}" }); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const get = await fetch(endpoint, { headers }); + expect(get.status).toBe(401); + const legacy = await connect(server.wsUrl, undefined, { Authorization: `Bearer ${MASTER}` }); + await expectRejected(legacy.client.serverAuth.issueWebSocketTicket(), "UNAUTHORIZED"); +}); + +test("ticket authority is object identity, never forgeable by matching headers", async () => { + const headers = { authorization: "Bearer invalid" }; + authorizeWebSocketTicketHeaders(headers); + const context = { + headers, + serverService: { isShuttingDown: () => false, getSshHost: () => "authenticated" }, + } as unknown as ORPCContext; + const authorized = createRouterClient(router(MASTER), { context }); + expect(await authorized.server.getSshHost()).toBe("authenticated"); + const forged = createRouterClient(router(MASTER), { + context: { ...context, headers: { ...headers } }, + }); + await expectRejected(forged.server.getSshHost(), "UNAUTHORIZED"); +}); + +test("a rejected WebSocket handshake after the ticket peek does not consume it", async () => { + await using server = await fixture(); + const issued = await server.client().serverAuth.issueWebSocketTicket(); + server.wsServer.options.verifyClient = () => false; + await expectRejected(connect(server.wsUrl, protocols(issued.ticket))); + server.wsServer.options.verifyClient = undefined; + const accepted = await connect(server.wsUrl, protocols(issued.ticket)); + expect(await accepted.client.server.getSshHost()).toBe("authenticated"); +}); + +test("two upgrades that both peek the same ticket emit only one authorized connection", async () => { + await using server = await fixture(); + const issued = await server.client().serverAuth.issueWebSocketTicket(); + const pending: Array<(accepted: boolean) => void> = []; + let ready!: () => void; + const bothPeeked = new Promise((resolve) => { + ready = resolve; + }); + server.wsServer.options.verifyClient = (_info: unknown, done: (accepted: boolean) => void) => { + pending.push(done); + if (pending.length === 2) ready(); + }; + let acceptedConnections = 0; + server.wsServer.on("connection", () => { + acceptedConnections++; + }); + const attempts = Promise.allSettled([ + connect(server.wsUrl, protocols(issued.ticket)), + connect(server.wsUrl, protocols(issued.ticket)), + ]); + await bothPeeked; + pending.forEach((done) => done(true)); + await attempts; + expect(acceptedConnections).toBe(1); + await expectRejected(connect(server.wsUrl, protocols(issued.ticket))); +}); diff --git a/src/node/orpc/webSocketTickets.test.ts b/src/node/orpc/webSocketTickets.test.ts new file mode 100644 index 00000000000..7e339e18dfe --- /dev/null +++ b/src/node/orpc/webSocketTickets.test.ts @@ -0,0 +1,64 @@ +import { afterEach, expect, mock, spyOn, test } from "bun:test"; +import { + ORPC_WS_PROTOCOL, + ORPC_WS_TICKET_PREFIX, + ORPC_WS_TICKET_TTL_MS, + ORPC_WS_TICKET_MAX_PENDING, +} from "@/common/constants/webSocketAuth"; +import { parseWebSocketTicketProtocols, WebSocketTicketStore } from "./webSocketTickets"; + +afterEach(() => mock.restore()); + +test("tickets expire at their deadline and two successful peeks authorize only one consumption", () => { + const clock = spyOn(Date, "now").mockReturnValue(1000); + const store = new WebSocketTicketStore(); + const first = store.mint()!; + expect(first.expiresAtMs).toBe(1000 + ORPC_WS_TICKET_TTL_MS); + expect(store.isValid(first.ticket)).toBe(true); + expect(store.isValid(first.ticket)).toBe(true); + expect(store.consume(first.ticket)).toBe(true); + expect(store.consume(first.ticket)).toBe(false); + const expired = store.mint()!; + clock.mockReturnValue(expired.expiresAtMs); + expect(store.isValid(expired.ticket)).toBe(false); + expect(store.consume(expired.ticket)).toBe(false); + expect(store.consume("unknown")).toBe(false); +}); + +test("tickets are bounded, reclaimed after expiry, and scoped to a live server store", () => { + const clock = spyOn(Date, "now").mockReturnValue(1000); + const store = new WebSocketTicketStore(); + const other = new WebSocketTicketStore(); + const tickets = Array.from({ length: ORPC_WS_TICKET_MAX_PENDING }, () => store.mint()!); + expect(new Set(tickets.map(({ ticket }) => ticket)).size).toBe(tickets.length); + expect(store.mint()).toBeNull(); + expect(other.consume(tickets[0].ticket)).toBe(false); + expect(store.isValid(tickets[0].ticket)).toBe(true); + clock.mockReturnValue(tickets[0].expiresAtMs); + const renewed = store.mint()!; + expect(renewed).not.toBeNull(); + store.dispose(); + expect(store.consume(renewed.ticket)).toBe(false); + expect(store.mint()).toBeNull(); +}); + +test("only a correctly ordered application/ticket pair uses ticket authentication", () => { + const ticket = new WebSocketTicketStore().mint()!.ticket; + const credential = `${ORPC_WS_TICKET_PREFIX}${ticket}`; + expect(parseWebSocketTicketProtocols(`${ORPC_WS_PROTOCOL}, ${credential}`)).toEqual({ + type: "ticket", + ticket, + }); + for (const header of [ + ORPC_WS_PROTOCOL, + credential, + `${credential}, ${ORPC_WS_PROTOCOL}`, + `${ORPC_WS_PROTOCOL}, ${credential}, extra`, + `${ORPC_WS_PROTOCOL}, ${ORPC_WS_TICKET_PREFIX}bad`, + `${ORPC_WS_PROTOCOL}, ${ORPC_WS_TICKET_PREFIX}${`A${ticket.slice(1)}`}`, + `${ORPC_WS_PROTOCOL}, ${credential}, ${credential}`, + ]) + expect(parseWebSocketTicketProtocols(header)).toEqual({ type: "invalid" }); + expect(parseWebSocketTicketProtocols(undefined)).toEqual({ type: "legacy" }); + expect(parseWebSocketTicketProtocols("legacy-token, other")).toEqual({ type: "legacy" }); +}); diff --git a/src/node/orpc/webSocketTickets.ts b/src/node/orpc/webSocketTickets.ts new file mode 100644 index 00000000000..b1a1fa516f1 --- /dev/null +++ b/src/node/orpc/webSocketTickets.ts @@ -0,0 +1,76 @@ +import { randomBytes } from "node:crypto"; +import type { WebSocketTicket } from "@/common/orpc/types"; +import { + ORPC_WS_PROTOCOL, + ORPC_WS_TICKET_PREFIX, + ORPC_WS_TICKET_TTL_MS, + ORPC_WS_TICKET_MAX_PENDING, + ORPC_WS_TICKET_PATTERN, +} from "@/common/constants/webSocketAuth"; + +/** Owned by one HTTP server: no bridge scopes, bearer material, or persistence. */ +export class WebSocketTicketStore { + private readonly tickets = new Map(); + private disposed = false; + + mint(): WebSocketTicket | null { + if (this.disposed) return null; + const now = Date.now(); + for (const [ticket, expiry] of this.tickets) { + if (expiry <= now) this.tickets.delete(ticket); + } + if (this.tickets.size >= ORPC_WS_TICKET_MAX_PENDING) return null; + let ticket: string; + do { + ticket = randomBytes(32).toString("hex"); + } while (this.tickets.has(ticket)); + const expiresAtMs = now + ORPC_WS_TICKET_TTL_MS; + this.tickets.set(ticket, expiresAtMs); + return { ticket, expiresAtMs }; + } + + isValid(ticket: string): boolean { + if (!ORPC_WS_TICKET_PATTERN.test(ticket)) return false; + const expiry = this.tickets.get(ticket); + return expiry !== undefined && Date.now() < expiry; + } + + /** Synchronous consume is the authorization point, not the earlier handshake peek. */ + consume(ticket: string): boolean { + const valid = this.isValid(ticket); + this.tickets.delete(ticket); + return valid; + } + + dispose(): void { + this.disposed = true; + this.tickets.clear(); + } +} + +type TicketProtocols = + | { type: "legacy" } + | { type: "invalid" } + | { type: "ticket"; ticket: string }; + +export function parseWebSocketTicketProtocols( + header: string | string[] | undefined +): TicketProtocols { + const protocols = (Array.isArray(header) ? header.join(",") : (header ?? "")) + .split(",") + .map((value) => value.trim()); + if ( + !protocols.some( + (value) => value === ORPC_WS_PROTOCOL || value.startsWith(ORPC_WS_TICKET_PREFIX) + ) + ) + return { type: "legacy" }; + if ( + protocols.length !== 2 || + protocols[0] !== ORPC_WS_PROTOCOL || + !protocols[1].startsWith(ORPC_WS_TICKET_PREFIX) + ) + return { type: "invalid" }; + const ticket = protocols[1].slice(ORPC_WS_TICKET_PREFIX.length); + return ORPC_WS_TICKET_PATTERN.test(ticket) ? { type: "ticket", ticket } : { type: "invalid" }; +} diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 7e1d34a7faa..b821c26a0a7 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -87,12 +87,12 @@ import type { TurnCoordinator } from "@/node/services/turnCoordinator"; import { registerInProcessWorkflowRun } from "@/node/services/workflows/workflowArchiveAdmission"; /** - * Independent field → tag listing for every ORPC context field (the production + * Independent field → tag listing for every service-backed ORPC context field (the production * mapping lives in the Layer files); `Record` keeps it exhaustive, so * a field added to `ORPCContext` without a tag fails to compile here. */ const ORPC_FIELD_TAGS: Record< - keyof Omit, + keyof Omit, Context.Key > = { config: ConfigTag, From c2576373fd5566ce4972d2f7756b2c9f468dd2cb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 03:12:47 +0000 Subject: [PATCH 68/84] =?UTF-8?q?=F0=9F=A4=96=20fix(auth):=20add=20rendere?= =?UTF-8?q?r-safe=20WebSocket=20ticket=20acquisition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exchange the bearer only over authenticated HTTP oRPC, validate ticket responses, and expose credential-safe cancellation/error reasons for browser and native clients. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$305.96`_ --- src/common/orpc/webSocketTicket.test.ts | 154 ++++++++++++++++++++++++ src/common/orpc/webSocketTicket.ts | 109 +++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 src/common/orpc/webSocketTicket.test.ts create mode 100644 src/common/orpc/webSocketTicket.ts diff --git a/src/common/orpc/webSocketTicket.test.ts b/src/common/orpc/webSocketTicket.test.ts new file mode 100644 index 00000000000..7ee658e3d94 --- /dev/null +++ b/src/common/orpc/webSocketTicket.test.ts @@ -0,0 +1,154 @@ +import { afterEach, expect, test } from "bun:test"; +import { requestWebSocketTicket, WebSocketTicketError } from "./webSocketTicket"; + +const originalFetch = globalThis.fetch; +const secret = "long-lived-master-secret"; +const ticket = "a".repeat(64); +const response = (value: unknown, status = 200) => Response.json({ json: value }, { status }); +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function mockFetch(implementation: (url: string, init?: RequestInit) => Promise) { + globalThis.fetch = implementation as typeof fetch; +} + +test("mints through prefixed HTTP oRPC with Authorization only, then returns a validated ticket", async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + mockFetch((url, init) => { + calls.push({ url, init }); + return Promise.resolve(response({ ticket, expiresAtMs: 1 })); + }); + const controller = new AbortController(); + // The server owns expiry; an apparently old timestamp must not fail under client clock skew. + expect( + await requestWebSocketTicket("https://example.test/@u/ws/apps/xum/", secret, controller.signal) + ).toEqual({ ticket, expiresAtMs: 1 }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe( + "https://example.test/@u/ws/apps/xum/orpc/serverAuth/issueWebSocketTicket" + ); + expect(calls[0].init?.method).toBe("POST"); + expect(new Headers(calls[0].init?.headers).get("Authorization")).toBe(`Bearer ${secret}`); + expect(calls[0].init).toMatchObject({ + credentials: "omit", + redirect: "error", + cache: "no-store", + signal: controller.signal, + }); + expect(calls[0].url).not.toContain(secret); + expect(calls[0].init?.body).toBeUndefined(); +}); + +test.each([ + [401, "authentication"], + [403, "transient"], + [404, "unsupported"], + [405, "unsupported"], + [503, "transient"], +] as const)( + "classifies HTTP %s without retaining upstream credential-bearing details", + async (status, reason) => { + mockFetch(() => + Promise.resolve(response({ message: secret, headers: { authorization: secret } }, status)) + ); + let failure: unknown; + try { + await requestWebSocketTicket("https://example.test", secret, new AbortController().signal); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(WebSocketTicketError); + expect(failure).toMatchObject({ reason }); + expect(String(failure)).not.toContain(secret); + expect(failure).not.toHaveProperty("cause"); + expect(JSON.stringify(failure)).not.toContain(secret); + } +); + +test.each([ + null, + {}, + { ticket: secret, expiresAtMs: 1 }, + { ticket: "A".repeat(64), expiresAtMs: 1 }, + { ticket: `${ticket}\r\n${secret}`, expiresAtMs: 1 }, + { ticket, expiresAtMs: "123" }, + { ticket, expiresAtMs: null }, +])("rejects malformed ticket responses without echoing them: %j", async (value) => { + mockFetch(() => Promise.resolve(response(value))); + expect( + await requestWebSocketTicket( + "https://example.test", + secret, + new AbortController().signal + ).catch((error: unknown) => error) + ).toMatchObject({ reason: "transient" }); +}); + +test("pre-cancellation avoids fetch; in-flight cancellation settles even if fetch ignores abort", async () => { + let calls = 0; + const started = Promise.withResolvers(); + const pending = Promise.withResolvers(); + mockFetch(() => { + calls++; + started.resolve(); + return pending.promise; + }); + const controller = new AbortController(); + controller.abort(secret); + expect( + await requestWebSocketTicket("https://example.test", secret, controller.signal).catch( + (error: unknown) => error + ) + ).toMatchObject({ reason: "cancelled" }); + expect(calls).toBe(0); + const lifetime = new AbortController(); + const request = requestWebSocketTicket("https://example.test", secret, lifetime.signal); + await started.promise; + lifetime.abort(secret); + expect(await request.catch((error: unknown) => error)).toMatchObject({ reason: "cancelled" }); + pending.resolve(response({ ticket, expiresAtMs: 1 })); + expect(calls).toBe(1); +}); + +test.each([ + `https://user:${secret}@example.test`, + `https://example.test/?token=${secret}`, + `https://example.test/#${secret}`, + "file:///tmp/server", +])("rejects unsafe base URLs before sending credentials", async (baseUrl) => { + let calls = 0; + mockFetch(() => { + calls++; + return Promise.resolve(response({ ticket, expiresAtMs: 1 })); + }); + expect( + await requestWebSocketTicket(baseUrl, secret, new AbortController().signal).catch( + (error: unknown) => error + ) + ).toMatchObject({ reason: "transient" }); + expect(calls).toBe(0); +}); + +test("network and malformed HTTP body failures are credential-safe and never retried", async () => { + for (const failure of [ + () => Promise.reject(new Error(secret)), + () => Promise.resolve(new Response(secret)), + ]) { + let calls = 0; + mockFetch(() => { + calls++; + return failure(); + }); + let error: unknown; + try { + await requestWebSocketTicket("https://example.test", secret, new AbortController().signal); + } catch (cause) { + error = cause; + } + expect(error).toMatchObject({ reason: "transient" }); + expect(String(error)).not.toContain(secret); + expect(error).not.toHaveProperty("cause"); + expect(calls).toBe(1); + } +}); diff --git a/src/common/orpc/webSocketTicket.ts b/src/common/orpc/webSocketTicket.ts new file mode 100644 index 00000000000..b956207704a --- /dev/null +++ b/src/common/orpc/webSocketTicket.ts @@ -0,0 +1,109 @@ +import { createORPCClient } from "@orpc/client"; +import type { Client, ClientContext } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import type { InferSchemaInput, InferSchemaOutput } from "@orpc/contract"; +import type { serverAuth } from "./schemas/api"; +import { ORPC_WS_TICKET_PATTERN } from "../constants/webSocketAuth"; + +type TicketProcedure = typeof serverAuth.issueWebSocketTicket; +type WebSocketTicket = InferSchemaOutput; +type TicketClient = Record< + "serverAuth", + { + issueWebSocketTicket: Client< + ClientContext, + InferSchemaInput, + WebSocketTicket, + Error + >; + } +>; +type TicketErrorReason = "authentication" | "unsupported" | "transient" | "cancelled"; +const errorMessages: Record = { + authentication: "Server authentication is required. Check your token.", + unsupported: "This server does not support secure WebSocket tickets. Update the server.", + transient: "Unable to obtain a secure connection ticket. Try connecting again.", + cancelled: "Connection cancelled.", +}; + +export class WebSocketTicketError extends Error { + constructor(readonly reason: TicketErrorReason) { + super(errorMessages[reason]); + this.name = "WebSocketTicketError"; + } +} + +/** Exchange a bearer over HTTP only. The caller owns the deadline and the resulting socket. */ +export async function requestWebSocketTicket( + baseUrl: string, + bearerToken: string, + signal: AbortSignal +): Promise { + if (signal.aborted) throw new WebSocketTicketError("cancelled"); + let cancel!: () => void; + // Native/custom fetch implementations may ignore abort. Settle the caller anyway, + // and never retain the abort reason or an upstream error that could contain credentials. + const cancelled = new Promise((_resolve, reject) => { + cancel = () => reject(new WebSocketTicketError("cancelled")); + signal.addEventListener("abort", cancel, { once: true }); + }); + try { + const base = new URL(baseUrl); + if ( + !/^https?:$/.test(base.protocol) || + base.username || + base.password || + base.search || + base.hash + ) { + throw new WebSocketTicketError("transient"); + } + const rpcPath = `${base.pathname.replace(/\/+$/, "")}/orpc`; + const client = createORPCClient( + new RPCLink({ + origin: base.origin, + url: `/${rpcPath.slice(1)}`, + method: "POST", + headers: { Authorization: `Bearer ${bearerToken}` }, + fetch: async (url, init) => { + if (signal.aborted) throw new WebSocketTicketError("cancelled"); + const response = await fetch(url, { + ...init, + credentials: "omit", + redirect: "error", + cache: "no-store", + }); + if (response.status === 401) throw new WebSocketTicketError("authentication"); + if ([404, 405, 501].includes(response.status)) + throw new WebSocketTicketError("unsupported"); + if (!response.ok) throw new WebSocketTicketError("transient"); + return response; + }, + }) + ); + const result: unknown = await Promise.race([ + client.serverAuth.issueWebSocketTicket(undefined, { signal }), + cancelled, + ]); + if (signal.aborted) throw new WebSocketTicketError("cancelled"); + if ( + !result || + typeof result !== "object" || + !("ticket" in result) || + typeof result.ticket !== "string" || + !ORPC_WS_TICKET_PATTERN.test(result.ticket) || + !("expiresAtMs" in result) || + typeof result.expiresAtMs !== "number" || + !Number.isFinite(result.expiresAtMs) + ) { + throw new WebSocketTicketError("transient"); + } + return { ticket: result.ticket, expiresAtMs: result.expiresAtMs }; + } catch (error) { + if (signal.aborted) throw new WebSocketTicketError("cancelled"); + if (error instanceof WebSocketTicketError) throw error; + throw new WebSocketTicketError("transient"); + } finally { + signal.removeEventListener("abort", cancel); + } +} From 5ff74944fad3a22017ba5857268dfed36a09b90b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 03:15:27 +0000 Subject: [PATCH 69/84] =?UTF-8?q?=F0=9F=A4=96=20fix(auth):=20migrate=20bro?= =?UTF-8?q?wser=20token=20sockets=20to=20single-use=20tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep bearer credentials out of WebSocket URLs and protocols. Own token connection attempts through ticket minting, upgrade and auth-check; preserve cookie-only and Electron transports and credential-safe reconnect behavior. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$305.96`_ --- src/browser/contexts/API.test.tsx | 239 ++++++++++++++++- src/browser/contexts/API.tsx | 420 +++++++++++++++++------------- 2 files changed, 472 insertions(+), 187 deletions(-) diff --git a/src/browser/contexts/API.test.tsx b/src/browser/contexts/API.test.tsx index 8247ece4933..7d7abc951c7 100644 --- a/src/browser/contexts/API.test.tsx +++ b/src/browser/contexts/API.test.tsx @@ -1,3 +1,4 @@ +import { ORPC_WS_PROTOCOL, ORPC_WS_TICKET_PREFIX } from "@/common/constants/webSocketAuth"; import { VERSION } from "@/version"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; @@ -8,11 +9,14 @@ import type { RecursivePartial } from "@/browser/testUtils"; class MockWebSocket { static instances: MockWebSocket[] = []; url: string; + protocols?: string[]; + protocol = ""; readyState = 0; // CONNECTING eventListeners = new Map void>>(); - constructor(url: string) { + constructor(url: string, protocols?: string[]) { this.url = url; + this.protocols = protocols; MockWebSocket.instances.push(this); } @@ -27,7 +31,8 @@ class MockWebSocket { } // Test helpers - simulateOpen() { + simulateOpen(protocol = this.protocols?.[0] ?? "") { + this.protocol = protocol; this.readyState = 1; // OPEN this.eventListeners.get("open")?.forEach((h) => h()); } @@ -134,7 +139,10 @@ function APIStateObserver(props: { onState: (state: ObservedState) => void }) { } // Factory that creates MockWebSocket instances (injected via prop) -const createMockWebSocket = (url: string) => new MockWebSocket(url) as unknown as WebSocket; +const createMockWebSocket = (url: string, protocols?: string[]) => + new MockWebSocket(url, protocols) as unknown as WebSocket; +const testTicket = "a".repeat(64); +const ticketResponse = (ticket = testTicket) => Response.json({ json: { ticket, expiresAtMs: 1 } }); describe("API reconnection", () => { beforeEach(() => { @@ -169,8 +177,13 @@ describe("API reconnection", () => { globalThis.document = undefined as unknown as Document; }); - test("constructs WebSocket URL with app proxy prefix", () => { + test("mints a ticket over prefixed HTTP and constructs a clean WebSocket URL", async () => { window.location.href = "https://coder.example.com/@u/ws/apps/mux/?token=abc"; + const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + fetchImpl = (input, init) => { + requests.push({ input, init }); + return Promise.resolve(ticketResponse()); + }; render( @@ -178,9 +191,221 @@ describe("API reconnection", () => { ); - const ws1 = MockWebSocket.lastInstance(); - expect(ws1).toBeDefined(); - expect(ws1!.url).toBe("wss://coder.example.com/@u/ws/apps/mux/orpc/ws?token=abc"); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)); + const ws1 = MockWebSocket.lastInstance()!; + expect(requests).toHaveLength(1); + expect(requests[0].input).toBe( + "https://coder.example.com/@u/ws/apps/mux/orpc/serverAuth/issueWebSocketTicket" + ); + expect(new Headers(requests[0].init?.headers).get("Authorization")).toBe("Bearer abc"); + expect(window.location.search).toBe(""); + expect(ws1.url).toBe("wss://coder.example.com/@u/ws/apps/mux/orpc/ws"); + expect(ws1.protocols).toEqual([ORPC_WS_PROTOCOL, `${ORPC_WS_TICKET_PREFIX}${testTicket}`]); + }); + + test("cookie-only browser connections do not mint tickets or offer auth protocols", async () => { + document.cookie = "mux-session=cookie-only-session"; + let requests = 0; + fetchImpl = () => { + requests++; + return Promise.resolve(ticketResponse()); + }; + const states: ObservedState[] = []; + render( + + states.push(value)} /> + + ); + const socket = MockWebSocket.lastInstance()!; + expect(socket.url).toBe("wss://mux.example.com/orpc/ws"); + expect(socket.protocols).toBeUndefined(); + await act(async () => { + socket.simulateOpen(); + await Promise.resolve(); + }); + expect(states.at(-1)?.status).toBe("connected"); + expect(requests).toBe(0); + }); + + test("token reconnects mint fresh tickets, and upgrade rejection does not erase the bearer", async () => { + storedAuthToken = "master-secret"; + let mints = 0; + const tickets = ["b".repeat(64), "c".repeat(64)]; + fetchImpl = () => Promise.resolve(ticketResponse(tickets[mints++])); + render( + + undefined} /> + + ); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)); + const first = MockWebSocket.lastInstance()!; + act(() => first.simulateClose(4401)); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(2)); + expect(MockWebSocket.lastInstance()!.protocols).toEqual([ + ORPC_WS_PROTOCOL, + `${ORPC_WS_TICKET_PREFIX}${tickets[1]}`, + ]); + expect(first.url).not.toContain("master-secret"); + expect(mints).toBe(2); + expect(clearStoredAuthTokenMock).not.toHaveBeenCalled(); + }); + + test.each([ + [401, "auth_required"], + [404, "error"], + ] as const)( + "ticket HTTP %s produces %s without a socket or secret fallback", + async (status, expected) => { + storedAuthToken = "master-secret"; + fetchImpl = () => Promise.resolve(new Response("master-secret", { status })); + let state: UseAPIResult | undefined; + render( + + { + state = value.apiState; + }} + /> + + ); + await waitFor(() => expect(state?.status).toBe(expected)); + expect(MockWebSocket.instances).toHaveLength(0); + expect(state?.error).not.toContain("master-secret"); + expect(clearStoredAuthTokenMock).toHaveBeenCalledTimes(status === 401 ? 1 : 0); + } + ); + + test("superseding token and unmount cancel pending ticket requests and discard late responses", async () => { + storedAuthToken = "old-secret"; + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const signals: AbortSignal[] = []; + fetchImpl = (_input, init) => { + signals.push(init!.signal!); + return signals.length === 1 ? first.promise : second.promise; + }; + let state: UseAPIResult | undefined; + const view = render( + + { + state = value.apiState; + }} + /> + + ); + await waitFor(() => expect(signals).toHaveLength(1)); + act(() => state!.authenticate("new-secret")); + await waitFor(() => expect(signals).toHaveLength(2)); + expect(signals[0].aborted).toBe(true); + await act(async () => { + first.resolve(ticketResponse()); + await Promise.resolve(); + }); + expect(MockWebSocket.instances).toHaveLength(0); + view.unmount(); + expect(signals[1].aborted).toBe(true); + await act(async () => { + second.resolve(ticketResponse()); + await Promise.resolve(); + }); + expect(MockWebSocket.instances).toHaveLength(0); + }); + + test("unmount closes a ticket-authenticated socket before its open or auth-check can publish", async () => { + storedAuthToken = "master-secret"; + fetchImpl = () => Promise.resolve(ticketResponse()); + const ping = Promise.withResolvers(); + pingImpl = () => ping.promise; + const observed: ObservedState[] = []; + const view = render( + + observed.push(value)} /> + + ); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)); + const socket = MockWebSocket.lastInstance()!; + act(() => socket.simulateOpen()); + view.unmount(); + expect(socket.readyState).toBe(3); + await act(async () => { + ping.resolve("pong"); + await Promise.resolve(); + }); + expect(observed.some((value) => value.status === "connected")).toBe(false); + }); + + test( + "a stalled ticket request times out into reconnect without opening a socket or clearing auth", + async () => { + storedAuthToken = "master-secret"; + const requests: AbortSignal[] = []; + fetchImpl = (_input, init) => { + requests.push(init!.signal!); + return new Promise(() => undefined); + }; + render( + + undefined} /> + + ); + await waitFor(() => expect(requests).toHaveLength(1)); + await waitFor(() => expect(requests).toHaveLength(2), { timeout: 12_000 }); + expect(requests[0].aborted).toBe(true); + expect(MockWebSocket.instances).toHaveLength(0); + expect(clearStoredAuthTokenMock).not.toHaveBeenCalled(); + }, + { timeout: 15_000 } + ); + + test("ticket sockets must negotiate the stable protocol before authenticated RPC", async () => { + storedAuthToken = "master-secret"; + let mints = 0; + let pings = 0; + fetchImpl = () => { + mints++; + return Promise.resolve(ticketResponse()); + }; + pingImpl = () => { + pings++; + return Promise.resolve("pong"); + }; + render( + + undefined} /> + + ); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)); + const wrongProtocol = MockWebSocket.lastInstance()!; + act(() => wrongProtocol.simulateOpen(`${ORPC_WS_TICKET_PREFIX}${testTicket}`)); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(2)); + expect(wrongProtocol.readyState).toBe(3); + expect(pings).toBe(0); + expect(mints).toBe(2); + expect(clearStoredAuthTokenMock).not.toHaveBeenCalled(); + }); + + test("HTTP authentication failures after reconnect stop the loop instead of reusing an expired ticket", async () => { + storedAuthToken = "master-secret"; + fetchImpl = () => Promise.resolve(ticketResponse()); + const states: ObservedState[] = []; + render( + + states.push(value)} /> + + ); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)); + const first = MockWebSocket.lastInstance()!; + await act(async () => { + first.simulateOpen(); + await Promise.resolve(); + }); + expect(states.at(-1)?.status).toBe("connected"); + fetchImpl = () => Promise.resolve(new Response("Unauthorized", { status: 401 })); + act(() => first.simulateClose(1006)); + await waitFor(() => expect(states.at(-1)?.status).toBe("auth_required")); + expect(MockWebSocket.instances).toHaveLength(1); + expect(clearStoredAuthTokenMock).toHaveBeenCalledTimes(1); }); test("injected clients skip internal auth token setup", async () => { diff --git a/src/browser/contexts/API.tsx b/src/browser/contexts/API.tsx index cf4e05e096d..4f1c3fe06d9 100644 --- a/src/browser/contexts/API.tsx +++ b/src/browser/contexts/API.tsx @@ -10,6 +10,8 @@ import { useMemo, } from "react"; import { createClient } from "@/common/orpc/client"; +import { requestWebSocketTicket, WebSocketTicketError } from "@/common/orpc/webSocketTicket"; +import { ORPC_WS_PROTOCOL, ORPC_WS_TICKET_PREFIX } from "@/common/constants/webSocketAuth"; import { RPCLink as WebSocketLink } from "@orpc/client/websocket"; import { RPCLink as MessagePortLink } from "@orpc/client/message-port"; import { @@ -59,6 +61,8 @@ const MAX_DELAY_MS = 10000; // browsers often hide the underlying HTTP status (401/403). We fetch /api/spec.json to // infer whether auth is required, but the probe must never block reconnect progress. const AUTH_PROBE_TIMEOUT_MS = 2000; +// Token attempts include ticket acquisition, upgrade and the authenticated ping. +const TOKEN_CONNECTION_TIMEOUT_MS = 10_000; // Liveness check constants. The probe measures backend round-trip time: a connection whose // transport is alive but whose server answers slowly must still surface as degraded. @@ -94,7 +98,7 @@ interface APIProviderProps { /** Optional pre-created client. If provided, skips internal connection setup. */ client?: APIClient; /** WebSocket factory for testing. Defaults to native WebSocket constructor. */ - createWebSocket?: (url: string) => WebSocket; + createWebSocket?: (url: string, protocols?: string[]) => WebSocket; } const noopConnectionControl = (_token?: string) => undefined; @@ -124,8 +128,8 @@ function createElectronClient(): { client: APIClient; cleanup: () => void } { } function createBrowserClient( - authToken: string | null, - createWebSocket: (url: string) => WebSocket + protocols: string[] | undefined, + createWebSocket: (url: string, protocols?: string[]) => WebSocket ): { client: APIClient; cleanup: () => void; @@ -135,11 +139,7 @@ function createBrowserClient( const wsUrl = new URL(`${apiBaseUrl}/orpc/ws`); wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; - if (authToken) { - wsUrl.searchParams.set("token", authToken); - } - - const ws = createWebSocket(wsUrl.toString()); + const ws = createWebSocket(wsUrl.toString(), protocols); // oRPC >=1.14 replaced the `websocket` option with a `connect` factory. const link = new WebSocketLink({ connect: () => ws }); @@ -219,7 +219,9 @@ function ManagedAPIProvider(props: Omit) { const authProbeAttemptedRef = useRef(false); const wsFactory = useMemo( - () => props.createWebSocket ?? ((url: string) => new WebSocket(url)), + () => + props.createWebSocket ?? + ((url: string, protocols?: string[]) => new WebSocket(url, protocols)), [props.createWebSocket] ); @@ -263,203 +265,260 @@ function ManagedAPIProvider(props: Omit) { ? { status: "reconnecting", attempt: Math.max(1, reconnectAttemptRef.current) } : { status: "connecting" } ); - const { client, cleanup, ws } = createBrowserClient(token, wsFactory); - ws.addEventListener("message", () => { - // Inbound frames prove the transport is alive, not that the backend is responsive: - // they only suppress the total-silence reconnect and never skip or satisfy a probe. - if (connectionId !== connectionIdRef.current) { - return; - } - - lastInboundBrowserFrameAtRef.current = performance.now(); - }); - - ws.addEventListener("open", () => { - // Ignore stale connections (can happen if we force reconnect while the old socket is mid-flight). - if (connectionId !== connectionIdRef.current) { - cleanup(); - return; - } - - client.general - .ping("auth-check") - .then(() => { - // Ignore stale connections (e.g., auth-check returned after a new connect()). - if (connectionId !== connectionIdRef.current) { - cleanup(); - return; - } - - const reconnected = hasConnectedRef.current; - authRequiredRef.current = false; - hasConnectedRef.current = true; - reconnectAttemptRef.current = 0; - consecutiveSlowProbesRef.current = 0; - forceReconnectInProgressRef.current = false; - window.__ORPC_CLIENT__ = client; - cleanupRef.current = cleanup; - setState({ status: "connected", client, cleanup }); - // A reconnected socket may belong to a newer server than this loaded bundle. The probe - // runs after the client is published so a slow /version never delays reconnection, and - // only a bundle served by that server can be refreshed by reloading, so split-origin - // setups (VITE_BACKEND_URL, extension webviews) skip it. - const backendBaseUrl = getBrowserBackendBaseUrl(); - if (reconnected && new URL(backendBaseUrl).origin === window.location.origin) { - void reloadIfServerBuildChanged( - backendBaseUrl, - () => connectionId === connectionIdRef.current - ); - } - }) - .catch((err: unknown) => { - if (connectionId !== connectionIdRef.current) { - cleanup(); - return; - } - - forceReconnectInProgressRef.current = false; - const errMsg = getErrorMessage(err); - const errMsgLower = errMsg.toLowerCase(); - const isAuthError = - errMsgLower.includes("unauthorized") || - errMsgLower.includes("401") || - errMsgLower.includes("auth token") || - errMsgLower.includes("authentication"); - - if (isAuthError) { - authRequiredRef.current = true; - clearStoredAuthToken(); - hasConnectedRef.current = false; // Reset - need fresh auth - setState({ status: "auth_required", error: token ? "Invalid token" : undefined }); - cleanup(); - return; - } - - cleanup(); - setState({ status: "error", error: errMsg }); - }); - }); - - // Note: Browser fires 'error' before 'close', so we handle reconnection - // only in 'close' to avoid double-scheduling. The 'error' event just - // signals that something went wrong; 'close' provides the final state. - ws.addEventListener("error", () => { - // Error occurred - close event will follow and handle reconnection - // We don't call cleanup() here since close handler will do it - }); - - ws.addEventListener("close", (event) => { + const controller = new AbortController(); + let retired = false; + let socketCleanup: () => void = () => undefined; + let timeout: ReturnType | undefined; + const isCurrent = () => !retired && connectionId === connectionIdRef.current; + const cleanup = () => { + if (retired) return; + retired = true; + clearTimeout(timeout); + controller.abort(); + socketCleanup(); + }; + // Own pending HTTP/upgrade work before the first await, not only an authenticated socket. + cleanupRef.current = cleanup; + const failAttempt = (error: unknown) => { + if (!isCurrent()) return; cleanup(); - - // Ignore stale connections (can happen if we force reconnect while the old socket is mid-flight). - if (connectionId !== connectionIdRef.current) { - return; - } - forceReconnectInProgressRef.current = false; - - // If we've already decided auth is required (e.g. via ping error), don't immediately - // overwrite the modal with a reconnect attempt. - // Auth-specific close codes - if (event.code === 1008 || event.code === 4401) { + if (error instanceof WebSocketTicketError && error.reason === "authentication") { authRequiredRef.current = true; clearStoredAuthToken(); - hasConnectedRef.current = false; // Reset - need fresh auth - setState({ status: "auth_required", error: "Authentication required" }); - return; - } - - if (authRequiredRef.current) { - return; + hasConnectedRef.current = false; + setState({ status: "auth_required", error: error.message }); + } else if (error instanceof WebSocketTicketError && error.reason === "unsupported") { + setState({ status: "error", error: error.message }); + } else { + scheduleReconnectRef.current?.(); } + }; + const openBrowserConnection = (protocols?: string[]) => { + const { client, cleanup: closeSocket, ws } = createBrowserClient(protocols, wsFactory); + socketCleanup = closeSocket; + ws.addEventListener("message", () => { + // Inbound frames prove the transport is alive, not that the backend is responsive: + // they only suppress the total-silence reconnect and never skip or satisfy a probe. + if (!isCurrent()) { + return; + } - // If this is the initial connection attempt and the WS handshake failed, browsers often - // collapse HTTP auth errors (401/403) into an abnormal closure (1006) with no status. - // - // If the backend is reachable over HTTP, we can use the OpenAPI spec to disambiguate: - // the server includes a `security` stanza when a bearer token is required. - if ( - !hasConnectedRef.current && - !token && - event.code === 1006 && - !authProbeAttemptedRef.current - ) { - authProbeAttemptedRef.current = true; - - const apiBaseUrl = getBrowserBackendBaseUrl(); - const specUrl = new URL(`${apiBaseUrl}/api/spec.json`); + lastInboundBrowserFrameAtRef.current = performance.now(); + }); - type AuthProbeResult = "requires_auth" | "no_auth" | "unknown"; + ws.addEventListener("open", () => { + // Ignore stale connections (can happen if we force reconnect while the old socket is mid-flight). + if (!isCurrent()) { + cleanup(); + return; + } - const controller = new AbortController(); - let timeoutId: ReturnType | null = null; + if (token && ws.protocol !== ORPC_WS_PROTOCOL) { + failAttempt(new WebSocketTicketError("transient")); + return; + } - // `fetch` has no builtin timeout, and some environments don't reliably reject on abort. - // Use a race so the probe cannot hang the connection loop. - const timeoutPromise = new Promise((resolve) => { - timeoutId = setTimeout(() => { - controller.abort(); - resolve("unknown"); - }, AUTH_PROBE_TIMEOUT_MS); - }); + client.general + .ping("auth-check", { signal: controller.signal }) + .then(() => { + // Ignore stale connections (e.g., auth-check returned after a new connect()). + if (!isCurrent()) { + cleanup(); + return; + } - const fetchPromise: Promise = fetch(specUrl, { - signal: controller.signal, - }) - .then(async (res): Promise => { - if (!res.ok) return "unknown"; - - try { - const spec = (await res.json()) as { security?: unknown }; - const requiresAuth = Array.isArray(spec.security) && spec.security.length > 0; - return requiresAuth ? "requires_auth" : "no_auth"; - } catch { - return "unknown"; + clearTimeout(timeout); + const reconnected = hasConnectedRef.current; + authRequiredRef.current = false; + hasConnectedRef.current = true; + reconnectAttemptRef.current = 0; + consecutiveSlowProbesRef.current = 0; + forceReconnectInProgressRef.current = false; + window.__ORPC_CLIENT__ = client; + cleanupRef.current = cleanup; + setState({ status: "connected", client, cleanup }); + // A reconnected socket may belong to a newer server than this loaded bundle. The probe + // runs after the client is published so a slow /version never delays reconnection, and + // only a bundle served by that server can be refreshed by reloading, so split-origin + // setups (VITE_BACKEND_URL, extension webviews) skip it. + const backendBaseUrl = getBrowserBackendBaseUrl(); + if (reconnected && new URL(backendBaseUrl).origin === window.location.origin) { + void reloadIfServerBuildChanged( + backendBaseUrl, + () => connectionId === connectionIdRef.current + ); } }) - .catch((): AuthProbeResult => "unknown"); - - void Promise.race([fetchPromise, timeoutPromise]) - .then((result) => { - if (connectionId !== connectionIdRef.current) { + .catch((err: unknown) => { + if (!isCurrent()) { + cleanup(); return; } - if (result === "requires_auth") { + forceReconnectInProgressRef.current = false; + const errMsg = getErrorMessage(err); + const errMsgLower = errMsg.toLowerCase(); + const isAuthError = + errMsgLower.includes("unauthorized") || + errMsgLower.includes("401") || + errMsgLower.includes("auth token") || + errMsgLower.includes("authentication"); + + if (isAuthError && !token) { authRequiredRef.current = true; clearStoredAuthToken(); hasConnectedRef.current = false; // Reset - need fresh auth - setState({ status: "auth_required", error: "Authentication required" }); + setState({ status: "auth_required", error: token ? "Invalid token" : undefined }); + cleanup(); return; } - if (result === "unknown") { - // Probe was inconclusive (timeout, network error, non-OK, invalid JSON). Allow re-probe - // on a later initial-handshake failure. - authProbeAttemptedRef.current = false; - } + cleanup(); + if (token) scheduleReconnectRef.current?.(); + else setState({ status: "error", error: errMsg }); + }); + }); + + // Note: Browser fires 'error' before 'close', so we handle reconnection + // only in 'close' to avoid double-scheduling. The 'error' event just + // signals that something went wrong; 'close' provides the final state. + ws.addEventListener("error", () => { + // Error occurred - close event will follow and handle reconnection + // We don't call cleanup() here since close handler will do it + }); + + ws.addEventListener("close", (event) => { + const wasCurrent = isCurrent(); + cleanup(); - scheduleReconnectRef.current?.(); - }) - .finally(() => { - if (timeoutId) { - clearTimeout(timeoutId); - } + // Cleanup retires this attempt before a synchronous close callback can reconnect it. + if (!wasCurrent) return; + + forceReconnectInProgressRef.current = false; + + // If we've already decided auth is required (e.g. via ping error), don't immediately + // overwrite the modal with a reconnect attempt. + // Auth-specific close codes + if (!token && (event.code === 1008 || event.code === 4401)) { + authRequiredRef.current = true; + clearStoredAuthToken(); + hasConnectedRef.current = false; // Reset - need fresh auth + setState({ status: "auth_required", error: "Authentication required" }); + return; + } + + if (authRequiredRef.current) { + return; + } + + // If this is the initial connection attempt and the WS handshake failed, browsers often + // collapse HTTP auth errors (401/403) into an abnormal closure (1006) with no status. + // + // If the backend is reachable over HTTP, we can use the OpenAPI spec to disambiguate: + // the server includes a `security` stanza when a bearer token is required. + if ( + !hasConnectedRef.current && + !token && + event.code === 1006 && + !authProbeAttemptedRef.current + ) { + authProbeAttemptedRef.current = true; + + const apiBaseUrl = getBrowserBackendBaseUrl(); + const specUrl = new URL(`${apiBaseUrl}/api/spec.json`); + + type AuthProbeResult = "requires_auth" | "no_auth" | "unknown"; + + const controller = new AbortController(); + let timeoutId: ReturnType | null = null; + + // `fetch` has no builtin timeout, and some environments don't reliably reject on abort. + // Use a race so the probe cannot hang the connection loop. + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + controller.abort(); + resolve("unknown"); + }, AUTH_PROBE_TIMEOUT_MS); }); - return; - } - // If we were previously connected, try to reconnect - if (hasConnectedRef.current) { + const fetchPromise: Promise = fetch(specUrl, { + signal: controller.signal, + }) + .then(async (res): Promise => { + if (!res.ok) return "unknown"; + + try { + const spec = (await res.json()) as { security?: unknown }; + const requiresAuth = Array.isArray(spec.security) && spec.security.length > 0; + return requiresAuth ? "requires_auth" : "no_auth"; + } catch { + return "unknown"; + } + }) + .catch((): AuthProbeResult => "unknown"); + + void Promise.race([fetchPromise, timeoutPromise]) + .then((result) => { + if (connectionId !== connectionIdRef.current) { + return; + } + + if (result === "requires_auth") { + authRequiredRef.current = true; + clearStoredAuthToken(); + hasConnectedRef.current = false; // Reset - need fresh auth + setState({ status: "auth_required", error: "Authentication required" }); + return; + } + + if (result === "unknown") { + // Probe was inconclusive (timeout, network error, non-OK, invalid JSON). Allow re-probe + // on a later initial-handshake failure. + authProbeAttemptedRef.current = false; + } + + scheduleReconnectRef.current?.(); + }) + .finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); + + return; + } + // If we were previously connected, try to reconnect + if (hasConnectedRef.current) { + scheduleReconnectRef.current?.(); + return; + } + + // First connection failed. + // This can happen in dev-server mode if the UI boots before the backend is ready. + // Prefer retry/backoff over forcing the auth modal (auth will be detected via ping/close codes). scheduleReconnectRef.current?.(); - return; + }); + }; + if (token) { + timeout = setTimeout( + () => failAttempt(new WebSocketTicketError("transient")), + TOKEN_CONNECTION_TIMEOUT_MS + ); + requestWebSocketTicket(getBrowserBackendBaseUrl(), token, controller.signal) + .then(({ ticket }) => { + if (isCurrent()) + openBrowserConnection([ORPC_WS_PROTOCOL, `${ORPC_WS_TICKET_PREFIX}${ticket}`]); + }) + .catch(failAttempt); + } else { + // Cookie-only browser sessions keep their existing handshake and auth probe. + try { + openBrowserConnection(); + } catch (error) { + failAttempt(error); } - - // First connection failed. - // This can happen in dev-server mode if the UI boots before the backend is ready. - // Prefer retry/backoff over forcing the auth modal (auth will be detected via ping/close codes). - scheduleReconnectRef.current?.(); - }); + } }, [props.createWebSocket, wsFactory] ); @@ -494,6 +553,7 @@ function ManagedAPIProvider(props: Omit) { useEffect(() => { connect(authToken); return () => { + connectionIdRef.current += 1; cleanupRef.current?.(); if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); From c7dc714927214b397438fd652b97835cdaa8168e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 03:33:31 +0000 Subject: [PATCH 70/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20authenticat?= =?UTF-8?q?e=20WebSockets=20with=20single-use=20tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exchange the bearer through the shared HTTP ticket helper and open a clean WebSocket URL with the stable protocol and one-use ticket. Keep one deadline across ticket issuance and the authenticated probe, close cancelled native handshakes, and mint fresh credentials only on explicit reconnect. Cover real HTTP/WS RPC behind proxy prefixes, native abort signals, late mint and handshake completion, credential-safe failures, mutation non-retry, and preview binary frames with browser identity stripping and origin rejection. Validation: full mobile-check passed (154 tests, 1 opt-in integration test skipped), 20 focused web/native/preview tests passed, and root static-check passed under Bun 1.3.5. Nix formatting was skipped because Nix is unavailable. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$19.11`_ --- packages/mobile/scripts/preview.test.ts | 5 +- .../mobile/scripts/preview.websocket.test.ts | 57 ++++- packages/mobile/src/api.test.ts | 242 +++++++++++++++++- packages/mobile/src/api.ts | 62 +++-- 4 files changed, 326 insertions(+), 40 deletions(-) diff --git a/packages/mobile/scripts/preview.test.ts b/packages/mobile/scripts/preview.test.ts index c0efe0f2d8e..9155abad214 100644 --- a/packages/mobile/scripts/preview.test.ts +++ b/packages/mobile/scripts/preview.test.ts @@ -36,7 +36,8 @@ describe("fixed-target mobile preview", () => { const preview = await listen( createPreviewServer({ endpoint: `${endpoint}/@me/dev/apps/xum`, origin }) ); - const result = await fetch(`${preview}/__xum/orpc/workspace/list`, { + const result = await fetch(`${preview}/__xum/orpc/serverAuth/issueWebSocketTicket`, { + method: "POST", headers: { host, origin, @@ -48,7 +49,7 @@ describe("fixed-target mobile preview", () => { }); const body = await result.json(); expect(result.status).toBe(200); - expect(body.url).toBe("/@me/dev/apps/xum/orpc/workspace/list"); + expect(body.url).toBe("/@me/dev/apps/xum/orpc/serverAuth/issueWebSocketTicket"); expect(body.headers.authorization).toBe("Bearer test-only"); expect(body.headers.origin).toBe(endpoint); expect(body.headers.cookie).toBeUndefined(); diff --git a/packages/mobile/scripts/preview.websocket.test.ts b/packages/mobile/scripts/preview.websocket.test.ts index a1074a0d217..5bd1038b202 100644 --- a/packages/mobile/scripts/preview.websocket.test.ts +++ b/packages/mobile/scripts/preview.websocket.test.ts @@ -2,16 +2,30 @@ import { expect, test } from "bun:test"; import net from "node:net"; import type { AddressInfo } from "node:net"; import { fileURLToPath } from "node:url"; +import { once } from "node:events"; +import { WebSocket } from "ws"; +import { + ORPC_WS_PROTOCOL, + ORPC_WS_TICKET_PREFIX, +} from "../../../src/common/constants/webSocketAuth"; // Exercise the same Node entry used by make mobile-web. Bun's node:http proxy // accepts upgrades but can stall binary oRPC frames, invisible to HTTP-only tests. -test("Node preview forwards binary WebSocket frames with prefix and token intact", async () => { +test("Node preview forwards binary WebSocket frames with ticket protocols intact and browser identity stripped", async () => { + const ticket = "a".repeat(64); + const protocols = [ORPC_WS_PROTOCOL, ORPC_WS_TICKET_PREFIX + ticket]; + const requests: Request[] = []; const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(req, server) { const url = new URL(req.url); - if (url.pathname !== "/prefix/orpc/ws" || url.searchParams.get("token") !== "test-only") + requests.push(req); + if ( + url.pathname !== "/prefix/orpc/ws" || + url.search || + req.headers.get("sec-websocket-protocol")?.split(/,\s*/).join(",") !== protocols.join(",") + ) return new Response("Unauthorized", { status: 401 }); return server.upgrade(req) ? undefined : new Response("Upgrade required", { status: 400 }); }, @@ -56,7 +70,14 @@ test("Node preview forwards binary WebSocket frames with prefix and token intact ]); clearTimeout(timeout); const bytes = new Uint8Array([0, 128, 255, 10]); - socket = new WebSocket(`ws://127.0.0.1:${port}/__xum/orpc/ws?token=test-only`); + socket = new WebSocket(`ws://127.0.0.1:${port}/__xum/orpc/ws`, protocols, { + headers: { + origin: `http://127.0.0.1:${port}`, + cookie: "private-preview-cookie", + forwarded: "host=attacker.test", + "x-forwarded-host": "attacker.test", + }, + }); socket.binaryType = "arraybuffer"; const reply = await new Promise((resolve, reject) => { const ws = socket!; @@ -66,6 +87,36 @@ test("Node preview forwards binary WebSocket frames with prefix and token intact ws.onerror = () => reject(new Error("Proxy WebSocket failed")); }); expect(new Uint8Array(reply)).toEqual(bytes); + expect(socket.protocol).toBe(protocols[0]); + expect(requests).toHaveLength(1); + expect(requests[0].headers.get("cookie")).toBeNull(); + expect(requests[0].headers.get("authorization")).toBeNull(); + expect(requests[0].headers.get("forwarded")).toBeNull(); + expect(requests[0].headers.get("x-forwarded-host")).toBeNull(); + expect(requests[0].headers.get("origin")).toBe(`http://127.0.0.1:${upstream.port}`); + const rejected = net.createConnection({ host: "127.0.0.1", port }); + try { + await once(rejected, "connect"); + rejected.write( + [ + "GET /__xum/orpc/ws HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Connection: Upgrade", + "Upgrade: websocket", + "Origin: https://attacker.test", + "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + `Sec-WebSocket-Protocol: ${protocols.join(", ")}`, + "", + "", + ].join("\r\n") + ); + const [response] = await once(rejected, "data"); + expect(String(response)).toContain("403 Forbidden"); + expect(requests).toHaveLength(1); + } finally { + rejected.destroy(); + } } finally { clearTimeout(timeout); socket?.close(); diff --git a/packages/mobile/src/api.test.ts b/packages/mobile/src/api.test.ts index 901954f9b85..51ff3077884 100644 --- a/packages/mobile/src/api.test.ts +++ b/packages/mobile/src/api.test.ts @@ -1,11 +1,20 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { ORPCError, os } from "@orpc/server"; import { RPCHandler } from "@orpc/server/websocket"; +import { RPCHandler as HTTPHandler } from "@orpc/server/node"; +import { createServer } from "node:http"; +import type { IncomingMessage } from "node:http"; +import { randomBytes } from "node:crypto"; import { z } from "zod"; import { once } from "node:events"; import assert from "node:assert/strict"; import { WebSocketServer } from "ws"; import { connect } from "./api"; +import { + ORPC_WS_PROTOCOL, + ORPC_WS_TICKET_PREFIX, + ORPC_WS_TICKET_TTL_MS, +} from "../../../src/common/constants/webSocketAuth"; import type { WorkspaceChatMessage } from "./transcript"; async function expectFailure(promise: Promise, message?: string): Promise { @@ -31,7 +40,17 @@ async function serverFixture(stallProbe = false) { if (!context.authenticated) throw new ORPCError("UNAUTHORIZED"); return next(); }); - const handler = new RPCHandler({ + const tickets = new Set(); + const mintRequests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + const handshakes: Array<{ url: string | undefined; protocols: string | undefined }> = []; + const router = { + serverAuth: { + issueWebSocketTicket: procedure.handler(() => { + const ticket = randomBytes(32).toString("hex"); + tickets.add(ticket); + return { ticket, expiresAtMs: Date.now() + ORPC_WS_TICKET_TTL_MS }; + }), + }, workspace: { list: procedure.handler(async ({ signal }) => { calls++; @@ -58,23 +77,60 @@ async function serverFixture(stallProbe = false) { yield { type: "caught-up", replay: "full" }; }), }, + }; + const handler = new RPCHandler(router); + const httpHandler = new HTTPHandler(router); + const httpServer = createServer((request, response) => { + mintRequests.push({ url: request.url, authorization: request.headers.authorization }); + httpHandler + .handle(request, response, { + prefix: "/proxy/orpc", + context: { authenticated: request.headers.authorization === `Bearer ${token}` }, + }) + .then(({ matched }) => { + if (!matched) { + response.statusCode = 404; + response.end(); + } + }) + .catch(() => { + response.statusCode = 500; + response.end(); + }); + }); + const server = new WebSocketServer({ + server: httpServer, + path: "/proxy/orpc/ws", + handleProtocols: () => ORPC_WS_PROTOCOL, + verifyClient: ({ req }: { req: IncomingMessage }) => { + const protocols = req.headers["sec-websocket-protocol"]?.split(/,\s*/); + const ticket = protocols + ?.find((value) => value.startsWith(ORPC_WS_TICKET_PREFIX)) + ?.slice(ORPC_WS_TICKET_PREFIX.length); + handshakes.push({ url: req.url, protocols: req.headers["sec-websocket-protocol"] }); + return ( + req.url === "/proxy/orpc/ws" && + protocols?.includes(ORPC_WS_PROTOCOL) === true && + ticket !== undefined && + tickets.delete(ticket) + ); + }, }); - const server = new WebSocketServer({ host: "127.0.0.1", port: 0, path: "/proxy/orpc/ws" }); - server.on("connection", (socket, request) => { + server.on("connection", (socket) => { upgrades++; - const url = new URL(request.url ?? "", "http://localhost"); - handler.upgrade(socket, { - context: { authenticated: url.searchParams.get("token") === token }, - }); + handler.upgrade(socket, { context: { authenticated: true } }); socket.once("close", onClose); onOpen(); }); - await once(server, "listening"); - const address = server.address(); + httpServer.listen(0, "127.0.0.1"); + await once(httpServer, "listening"); + const address = httpServer.address(); assert(address && typeof address !== "string", "Test server must bind a TCP port"); return { endpoint: `http://127.0.0.1:${address.port}/proxy`, token, + mintRequests, + handshakes, opened, closed, calls: () => calls, @@ -85,6 +141,12 @@ async function serverFixture(stallProbe = false) { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); + await new Promise((resolve, reject) => { + // Bun can stop listening here after terminating an upgraded connection. + httpServer.closeAllConnections(); + if (!httpServer.listening) resolve(); + else httpServer.close((error) => (error ? reject(error) : resolve())); + }); }, }; } @@ -113,6 +175,15 @@ describe("mobile WebSocket connection", () => { try { expect(connection.endpoint).toBe(server.endpoint); expect(server.calls()).toBe(1); + expect(server.mintRequests).toEqual([ + { + url: "/proxy/orpc/serverAuth/issueWebSocketTicket", + authorization: `Bearer ${server.token}`, + }, + ]); + expect(server.handshakes[0].url).toBe("/proxy/orpc/ws"); + expect(server.handshakes[0].protocols).not.toContain(server.token); + const events: WorkspaceChatMessage[] = []; const subscription = await connection.client.workspace.onChat({ workspaceId: "w", @@ -149,14 +220,14 @@ describe("mobile WebSocket connection", () => { expect(server.upgrades()).toBe(1); }); - test("rejects bad auth without exposing token or URL and closes its socket", async () => { + test("rejects bad auth without exposing token or URL or opening a socket", async () => { await using server = await serverFixture(); const secret = "wrong-secret"; const error = await connect(server.endpoint, secret).catch((error: unknown) => error); expect(error).toBeInstanceOf(Error); expect(String(error)).not.toContain(secret); expect(String(error)).not.toContain(server.endpoint); - await server.closed; + expect(server.upgrades()).toBe(0); expect(server.calls()).toBe(0); }); @@ -169,6 +240,11 @@ describe("mobile WebSocket connection", () => { hostname: "127.0.0.1", port: 0, fetch(request) { + if (new URL(request.url).pathname === "/orpc/serverAuth/issueWebSocketTicket") { + return Response.json({ + json: { ticket: "a".repeat(64), expiresAtMs: Date.now() + ORPC_WS_TICKET_TTL_MS }, + }); + } onRequest(request); return new Promise((resolve) => { request.signal.addEventListener( @@ -223,6 +299,148 @@ describe("mobile WebSocket connection", () => { expect(server.upgrades()).toBe(1); }); + test("explicit reconnect mints a fresh ticket without replaying a failed mutation", async () => { + await using server = await serverFixture(); + const first = await connect(server.endpoint, server.token); + await expectFailure(first.client.workspace.interruptStream({ workspaceId: "w" })); + first.close(); + const second = await first.reconnect(); + try { + expect(server.mintRequests).toHaveLength(2); + expect(server.handshakes).toHaveLength(2); + expect(server.handshakes[0].protocols).not.toBe(server.handshakes[1].protocols); + expect(server.mutations()).toBe(1); + } finally { + second.close(); + } + }); + + test.each(["cancel", "timeout"] as const)( + "%s bounds ticket acquisition even when fetch ignores abort", + async (action) => { + await using server = await serverFixture(); + let finishFetch!: (response: Response) => void; + let requested!: () => void; + const started = new Promise((resolve) => { + requested = resolve; + }); + const response = new Promise((resolve) => { + finishFetch = resolve; + }); + let requestSignal: AbortSignal | undefined; + const originalTimeout = globalThis.setTimeout; + let expire!: () => void; + const timer = spyOn(globalThis, "setTimeout").mockImplementation( + Object.assign((...args: Parameters) => { + const [callback, delay, ...callbackArgs] = args; + if (delay === 10_000) expire = () => callback(...callbackArgs); + return originalTimeout(...args); + }, originalTimeout) + ); + const fetchMock = spyOn(globalThis, "fetch").mockImplementation( + Object.assign((...args: Parameters) => { + requestSignal = args[1]?.signal ?? undefined; + requested(); + return response; + }, globalThis.fetch) + ); + try { + const controller = new AbortController(); + const pending = connect(server.endpoint, server.token, { signal: controller.signal }); + await started; + if (action === "cancel") controller.abort("private cancellation reason"); + else expire(); + await expectFailure( + pending, + action === "cancel" ? "Connection cancelled." : "Connection timed out." + ); + expect(requestSignal?.aborted).toBe(true); + finishFetch( + Response.json({ + json: { ticket: "a".repeat(64), expiresAtMs: Date.now() + ORPC_WS_TICKET_TTL_MS }, + }) + ); + await response; + await Promise.resolve(); + expect(server.handshakes).toHaveLength(0); + } finally { + fetchMock.mockRestore(); + timer.mockRestore(); + } + } + ); + + test("cancellation closes a late native handshake that could not close while connecting", async () => { + const OriginalWebSocket = globalThis.WebSocket; + let opened!: (socket: PendingSocket) => void; + const constructed = new Promise((resolve) => { + opened = resolve; + }); + class PendingSocket extends EventTarget { + readyState = 0; + binaryType = "blob"; + closes = 0; + constructor() { + super(); + opened(this); + } + close() { + this.closes++; + if (this.readyState === 0) throw new Error("Cannot close pending native handshake"); + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } + send() { + throw new Error("Cancelled socket must not dispatch RPCs"); + } + } + Object.assign(globalThis, { WebSocket: PendingSocket }); + const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ + json: { ticket: "a".repeat(64), expiresAtMs: Date.now() + ORPC_WS_TICKET_TTL_MS }, + }) + ); + try { + const controller = new AbortController(); + const pending = connect("http://localhost", "private token/+?", { + signal: controller.signal, + }); + const lateSocket = await constructed; + controller.abort(); + await expectFailure(pending, "Connection cancelled."); + expect(lateSocket.closes).toBe(1); + lateSocket.readyState = 1; + lateSocket.dispatchEvent(new Event("open")); + expect(lateSocket.closes).toBe(2); + expect(lateSocket.readyState).toBe(3); + } finally { + Object.assign(globalThis, { WebSocket: OriginalWebSocket }); + fetchMock.mockRestore(); + } + }); + + test.each([401, 404, 500])( + "ticket HTTP %s fails closed without an insecure upgrade or credential-bearing error", + async (status) => { + await using server = await serverFixture(); + const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(server.token, { status }) + ); + try { + const failure = await connect(server.endpoint, server.token).catch( + (cause: unknown) => cause + ); + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).not.toContain(server.token); + expect(String(failure)).not.toContain(server.endpoint); + expect(server.handshakes).toHaveLength(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + fetchMock.mockRestore(); + } + } + ); + test("a stalled authenticated RPC probe times out and closes its socket", async () => { await using server = await serverFixture(true); await expectFailure(connect(server.endpoint, server.token), "Connection timed out."); diff --git a/packages/mobile/src/api.ts b/packages/mobile/src/api.ts index 1cf14820ee4..d2ef1cb764b 100644 --- a/packages/mobile/src/api.ts +++ b/packages/mobile/src/api.ts @@ -3,6 +3,14 @@ import type { Client, ClientContext } from "@orpc/client"; import type { AnySchema, InferSchemaInput, InferSchemaOutput } from "@orpc/contract"; import { RPCLink } from "@orpc/client/websocket"; import type * as schemas from "../../../src/common/orpc/schemas/api"; +import { + ORPC_WS_PROTOCOL, + ORPC_WS_TICKET_PREFIX, +} from "../../../src/common/constants/webSocketAuth"; +import { + requestWebSocketTicket, + WebSocketTicketError, +} from "../../../src/common/orpc/webSocketTicket"; import { normalizeEndpoint } from "./endpoint"; // Infer the wire contract without importing the Node router's implementation @@ -39,47 +47,54 @@ export async function connect( if (!token.trim()) throw new Error("Enter a server token."); if (options.signal?.aborted) throw new Error("Connection cancelled."); - const url = new URL(`${normalized}/orpc/ws`); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - url.searchParams.set("token", token.trim()); - let socket: WebSocket; - try { - socket = new WebSocket(url.toString()); - socket.binaryType = "arraybuffer"; - } catch { - // Native WebSocket errors may include the credential-bearing URL. - throw new Error("Unable to open a connection to the server."); - } - const probe = new AbortController(); + let socket: WebSocket | undefined; let closed = false; let timedOut = false; + const closeSocket = () => { + if (!socket) return; + const ownedSocket = socket; + try { + if (ownedSocket.readyState < 2) ownedSocket.close(); + } catch { + // Some native implementations cannot close a pending handshake. Do not + // let a late open outlive cancellation of the connection that owns it. + ownedSocket.addEventListener("open", () => ownedSocket.close(), { once: true }); + } + }; const close = () => { if (closed) return; closed = true; options.signal?.removeEventListener("abort", close); - socket.removeEventListener("close", close); + socket?.removeEventListener("close", close); probe.abort(); - try { - if (socket.readyState < 2) socket.close(); - } catch { - // Some native implementations throw when closing a pending handshake. - // Still close if that handshake subsequently succeeds. - socket.addEventListener("open", () => socket.close(), { once: true }); - } + closeSocket(); }; - socket.addEventListener("close", close); options.signal?.addEventListener("abort", close, { once: true }); + // One deadline covers both the HTTP mint and the authenticated socket probe. const timeout = setTimeout(() => { timedOut = true; close(); }, CONNECT_TIMEOUT_MS); try { + const { ticket } = await requestWebSocketTicket(normalized, token, probe.signal); + probe.signal.throwIfAborted(); + const url = new URL(`${normalized}/orpc/ws`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + // Never put the reusable bearer in URLs or protocols; every reconnect mints + // a fresh, short-lived single-use ticket through authenticated HTTP instead. + socket = new WebSocket(url.toString(), [ORPC_WS_PROTOCOL, ORPC_WS_TICKET_PREFIX + ticket]); + socket.binaryType = "arraybuffer"; + socket.addEventListener("close", close); + if (closed) { + closeSocket(); + throw new Error("Connection closed."); + } const client = createORPCClient( new RPCLink({ connect: () => { - if (closed) throw new Error("Connection closed."); + if (closed || !socket) throw new Error("Connection closed."); return socket; }, reconnect: { enabled: false }, @@ -102,10 +117,11 @@ export async function connect( endpoint: normalized, reconnect: (options) => connect(normalized, token, options), }; - } catch { + } catch (error) { close(); if (options.signal?.aborted) throw new Error("Connection cancelled."); if (timedOut) throw new Error("Connection timed out."); + if (error instanceof WebSocketTicketError) throw error; throw new Error("Unable to connect. Check the server address and token."); } finally { clearTimeout(timeout); From b350322fd332dd764b30e4e68e20bf31f39c61ef Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 03:42:55 +0000 Subject: [PATCH 71/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20require=20t?= =?UTF-8?q?he=20negotiated=20oRPC=20application=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject and close ticket-echo or missing protocol handshakes after the auth probe instead of publishing a client. Exercise a real ticket-echo handshake with a successful RPC probe and verify sanitized errors and socket closure. Validation: focused API/native tests passed (17), full mobile-check passed (155 tests, 1 opt-in real-server test skipped), and root static-check passed. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$27.47`_ --- packages/mobile/src/api.test.ts | 43 +++++++++++++++++++++++++++++++-- packages/mobile/src/api.ts | 2 ++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/mobile/src/api.test.ts b/packages/mobile/src/api.test.ts index 51ff3077884..e07609033c4 100644 --- a/packages/mobile/src/api.test.ts +++ b/packages/mobile/src/api.test.ts @@ -23,7 +23,10 @@ async function expectFailure(promise: Promise, message?: string): Promi if (message) expect(String(error)).toContain(message); } -async function serverFixture(stallProbe = false) { +async function serverFixture( + stallProbe = false, + selectProtocol: (protocols: Set) => string | false = () => ORPC_WS_PROTOCOL +) { const token = "private token/+?"; let calls = 0; let upgrades = 0; @@ -101,7 +104,7 @@ async function serverFixture(stallProbe = false) { const server = new WebSocketServer({ server: httpServer, path: "/proxy/orpc/ws", - handleProtocols: () => ORPC_WS_PROTOCOL, + handleProtocols: selectProtocol, verifyClient: ({ req }: { req: IncomingMessage }) => { const protocols = req.headers["sec-websocket-protocol"]?.split(/,\s*/); const ticket = protocols @@ -202,6 +205,42 @@ describe("mobile WebSocket connection", () => { expect(server.calls()).toBe(1); }); + test("rejects a ticket-echo protocol even when the server answers the auth probe", async () => { + await using server = await serverFixture( + false, + (protocols) => + [...protocols].find((protocol) => protocol.startsWith(ORPC_WS_TICKET_PREFIX)) ?? false + ); + const OriginalWebSocket = globalThis.WebSocket; + // Bun's ws fixture always selects the first offer on the wire, even when + // handleProtocols selects another. Offer the same protocols ticket-first to + // exercise a real ticket-echo handshake and probe rather than mock its result. + class TicketFirstSocket extends OriginalWebSocket { + constructor(url: string, protocols?: string | string[]) { + assert(Array.isArray(protocols)); + super(url, [...protocols].reverse()); + } + } + Object.assign(globalThis, { WebSocket: TicketFirstSocket }); + try { + const result = await connect(server.endpoint, server.token).catch((cause: unknown) => cause); + expect(result).toBeInstanceOf(Error); + expect(server.calls()).toBe(1); + expect(server.upgrades()).toBe(1); + const ticket = server.handshakes[0].protocols + ?.split(/,\s*/) + .find((protocol) => protocol.startsWith(ORPC_WS_TICKET_PREFIX)) + ?.slice(ORPC_WS_TICKET_PREFIX.length); + expect(ticket).toBeDefined(); + expect(String(result)).not.toContain(ticket!); + expect(String(result)).not.toContain(server.token); + expect(String(result)).not.toContain(server.endpoint); + await server.closed; + } finally { + Object.assign(globalThis, { WebSocket: OriginalWebSocket }); + } + }); + test("does not retry a failed mutation or dispatch mutations after close", async () => { await using server = await serverFixture(); const connection = await connect(server.endpoint, server.token); diff --git a/packages/mobile/src/api.ts b/packages/mobile/src/api.ts index d2ef1cb764b..4dbfcbe1249 100644 --- a/packages/mobile/src/api.ts +++ b/packages/mobile/src/api.ts @@ -111,6 +111,8 @@ export async function connect( // An open handshake alone does not prove RPC authentication succeeded. await client.workspace.list(undefined, { signal: probe.signal }); if (closed) throw new Error("Connection closed."); + // A ticket is an upgrade credential, never the negotiated application protocol. + if (socket.protocol !== ORPC_WS_PROTOCOL) throw new Error("Connection protocol rejected."); return { client, close, From 9106b6ed497febb723019fefc26e95f8a76074ba Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 03:51:43 +0000 Subject: [PATCH 72/84] =?UTF-8?q?=F0=9F=A4=96=20docs(mobile):=20explain=20?= =?UTF-8?q?secure=20WebSocket=20ticket=20requirements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the Authorization-only bearer exchange and fail-closed behavior when an older server does not support short-lived upgrade tickets. Refresh the built-in documentation snapshot generated by the validated static gate. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$1509.47`_ --- docs/integrations/mobile-app.md | 2 ++ src/node/services/agentSkills/builtInSkillContent.generated.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index 74a7b7874fd..a229b994198 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -15,6 +15,8 @@ During development, run the mobile client and server from the same branch/revisi The token grants access to the server, including its code-execution capabilities. Treat it like a password. Native builds save connection details in device secure storage. The web preview keeps them in memory only; refreshing requires entering them again. Disconnect clears the saved native connection. +Before opening a WebSocket, the companion exchanges the token in an HTTP Authorization header for a short-lived, single-use upgrade ticket. The long-lived token is not included in the WebSocket URL or subprotocols. Older servers without ticket support must be updated; there is no credential-URL fallback. + Public endpoints require HTTPS. Literal private LAN and loopback HTTP addresses are accepted for development, with a plaintext-token warning. Mobile platform transport policies may still restrict cleartext networking; prefer HTTPS on devices. A phone's `localhost` refers to the phone, not your development computer. ## Develop with React Native Web diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 9bdd7714fa6..8078bf07935 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -7091,6 +7091,8 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "The token grants access to the server, including its code-execution capabilities. Treat it like a password. Native builds save connection details in device secure storage. The web preview keeps them in memory only; refreshing requires entering them again. Disconnect clears the saved native connection.", "", + "Before opening a WebSocket, the companion exchanges the token in an HTTP Authorization header for a short-lived, single-use upgrade ticket. The long-lived token is not included in the WebSocket URL or subprotocols. Older servers without ticket support must be updated; there is no credential-URL fallback.", + "", "Public endpoints require HTTPS. Literal private LAN and loopback HTTP addresses are accepted for development, with a plaintext-token warning. Mobile platform transport policies may still restrict cleartext networking; prefer HTTPS on devices. A phone's `localhost` refers to the phone, not your development computer.", "", "## Develop with React Native Web", From d1c1a11265175b424fe2cebc581442a80f373845 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 04:38:49 +0000 Subject: [PATCH 73/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20require=20T?= =?UTF-8?q?LS=20for=20every=20non-loopback=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PRRT_kwDOPxxmWM6gF-0n by rejecting LAN, ULA, link-local, public, and DNS HTTP endpoints before ticket acquisition or preview discovery. Preserve loopback-only development, HTTPS proxy prefixes, literal URL validation, and credential-safe errors; update the warning and bundled docs. Validation: 70 endpoint/transport tests, fixed-target rejection before construction, mobile-check (178 passed; one real-server skip), and root static-check with Bun 1.3.5. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$141.19`_ --- docs/integrations/mobile-app.md | 2 +- packages/mobile/src/api.test.ts | 25 +++++++++ packages/mobile/src/endpoint.test.ts | 56 ++++++++++++++----- packages/mobile/src/endpoint.ts | 18 +++--- packages/mobile/src/screens/ConnectScreen.tsx | 4 +- .../builtInSkillContent.generated.ts | 2 +- 6 files changed, 77 insertions(+), 30 deletions(-) diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index a229b994198..a5b61661709 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -17,7 +17,7 @@ The token grants access to the server, including its code-execution capabilities Before opening a WebSocket, the companion exchanges the token in an HTTP Authorization header for a short-lived, single-use upgrade ticket. The long-lived token is not included in the WebSocket URL or subprotocols. Older servers without ticket support must be updated; there is no credential-URL fallback. -Public endpoints require HTTPS. Literal private LAN and loopback HTTP addresses are accepted for development, with a plaintext-token warning. Mobile platform transport policies may still restrict cleartext networking; prefer HTTPS on devices. A phone's `localhost` refers to the phone, not your development computer. +All non-loopback endpoints—including private LAN, ULA, and link-local addresses—require HTTPS. HTTP is accepted only for `localhost`, IPv4 loopback (`127.0.0.0/8`), or IPv6 loopback (`[::1]`) for development, with a plaintext-token warning. A phone's `localhost` refers to the phone, not your development computer: use a trusted HTTPS endpoint to reach that computer from a device. ## Develop with React Native Web diff --git a/packages/mobile/src/api.test.ts b/packages/mobile/src/api.test.ts index e07609033c4..83f2ba7e090 100644 --- a/packages/mobile/src/api.test.ts +++ b/packages/mobile/src/api.test.ts @@ -10,6 +10,7 @@ import { once } from "node:events"; import assert from "node:assert/strict"; import { WebSocketServer } from "ws"; import { connect } from "./api"; +import { connect as connectPreview } from "./connection.web"; import { ORPC_WS_PROTOCOL, ORPC_WS_TICKET_PREFIX, @@ -487,3 +488,27 @@ describe("mobile WebSocket connection", () => { expect(server.upgrades()).toBe(1); }, 15_000); }); + +test("non-loopback HTTP is rejected before ticket or preview fetch even with saved credentials", async () => { + const fetchMock = spyOn(globalThis, "fetch").mockRejectedValue( + new Error("Network must not be reached") + ); + try { + for (const endpoint of [ + "http://10.0.0.2:3000/prefix", + "http://192.168.1.2", + "http://172.16.0.1", + "http://169.254.1.2", + "http://[fd00::1]", + "http://[fe80::1]", + "http://server.local", + ]) { + const saved = { endpoint, token: "private token/+?" }; + await expectFailure(connect(saved.endpoint, saved.token), "HTTPS"); + await expectFailure(connectPreview(saved.endpoint, saved.token), "HTTPS"); + } + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + fetchMock.mockRestore(); + } +}); diff --git a/packages/mobile/src/endpoint.test.ts b/packages/mobile/src/endpoint.test.ts index cbe475519d0..489e04e2de8 100644 --- a/packages/mobile/src/endpoint.test.ts +++ b/packages/mobile/src/endpoint.test.ts @@ -26,6 +26,21 @@ describe("mobile endpoints", () => { "https://example.com\\@other.com", "https://exa\nmple.com", "http://example.com", + "http://10.0.0.2", + "http://172.16.0.1", + "http://172.31.255.254", + "http://192.168.1.2", + "http://169.254.1.2", + "http://[fc00::1]", + "http://[fd00::1]", + "http://[fe80::1]", + "http://[::ffff:127.0.0.1]", + "http://0x0a000001", + "http://3232235777", + "http://0300.0250.1.1", + "http://server.local", + "http://localhost.example.com", + "http://localhost.", "http://172.32.0.1", "http://192.169.0.1", "http://10.0.0.1.example.com", @@ -43,20 +58,31 @@ describe("mobile endpoints", () => { expect(String(error)).not.toContain("secret"); }); - test.each([ - "localhost", - "127.0.0.1", - "10.0.0.2", - "172.16.0.1", - "172.31.255.254", - "192.168.1.2", - "[::1]", - "[fd00::1]", - "[fe80::1]", - ])("allows explicit local development with a cleartext warning: %s", (host) => { - const endpoint = `http://${host}:3000/proxy`; - expect(normalizeEndpoint(endpoint)).toBe(endpoint); - expect(isInsecureEndpoint(endpoint)).toBe(true); - expect(isInsecureEndpoint(`https://${host}:3000/proxy`)).toBe(false); + test.each(["localhost", "127.0.0.1", "127.23.45.67", "127.255.255.254", "[::1]"])( + "allows loopback development with a cleartext warning: %s", + (host) => { + const endpoint = `http://${host}:3000/proxy`; + expect(normalizeEndpoint(endpoint)).toBe(endpoint); + expect(isInsecureEndpoint(endpoint)).toBe(true); + expect(isInsecureEndpoint(`https://${host}:3000/proxy`)).toBe(false); + } + ); + + test.each(["127.1", "0x7f000001", "2130706433", "0177.0.0.1"])( + "classifies noncanonical IPv4 through URL normalization: %s", + (host) => + expect(normalizeEndpoint(`http://${host}:3000/proxy`)).toBe("http://127.0.0.1:3000/proxy") + ); + test("accepts normalized IPv6 loopback", () => { + expect(normalizeEndpoint("http://[0:0:0:0:0:0:0:1]:3000/proxy")).toBe( + "http://[::1]:3000/proxy" + ); }); + test.each(["10.0.0.2", "192.168.1.2", "[fd00::1]", "[fe80::1]", "server.local", "example.com"])( + "allows remote HTTPS with a proxy prefix: %s", + (host) => + expect(normalizeEndpoint(`https://${host}/@user/workspace/apps/xum/`)).toBe( + `https://${host}/@user/workspace/apps/xum` + ) + ); }); diff --git a/packages/mobile/src/endpoint.ts b/packages/mobile/src/endpoint.ts index a50579f076a..24685c8cbe1 100644 --- a/packages/mobile/src/endpoint.ts +++ b/packages/mobile/src/endpoint.ts @@ -1,14 +1,12 @@ -function isLocalHost(hostname: string): boolean { +function isLoopbackHost(hostname: string): boolean { if (hostname === "localhost" || hostname === "[::1]") return true; - // Cleartext is a development-only escape hatch for literal LAN addresses, - // not arbitrary DNS names which could resolve to a public server. - if (/^\[(?:f[cd][\da-f]{2}:|fe[89ab][\da-f]:)/i.test(hostname)) return true; + // Ticket minting still sends the master bearer: private networks are not a + // confidentiality boundary. Classify URL-normalized literals without DNS lookups. const octets = hostname.split(".").map(Number); if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { return false; } - const [a, b] = octets; - return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); + return octets[0] === 127; } /** The endpoint is a server base URL, including any reverse-proxy path prefix. */ @@ -28,15 +26,13 @@ export function normalizeEndpoint(input: string): string { if (url.username || url.password || value.split("/")[2].includes("@")) { throw new Error("Enter the server token separately, not in the URL."); } - if (url.protocol === "http:" && !isLocalHost(url.hostname)) { - throw new Error( - "Remote servers require HTTPS. HTTP is only allowed for localhost or private LAN addresses." - ); + if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) { + throw new Error("Remote servers require HTTPS. HTTP is only allowed for loopback addresses."); } return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; } -/** Show a warning: native HTTP LAN development sends the token without TLS. */ +/** Loopback HTTP development still sends the token without TLS. */ export function isInsecureEndpoint(endpoint: string): boolean { return normalizeEndpoint(endpoint).startsWith("http:"); } diff --git a/packages/mobile/src/screens/ConnectScreen.tsx b/packages/mobile/src/screens/ConnectScreen.tsx index 302a56dd690..cd55bb84d6a 100644 --- a/packages/mobile/src/screens/ConnectScreen.tsx +++ b/packages/mobile/src/screens/ConnectScreen.tsx @@ -147,8 +147,8 @@ export function ConnectScreen(props: { onConnect: (connection: Connection) => vo {insecure && ( - HTTP is not encrypted. Your token and conversations can be read by others on the - network. Connect only on a trusted local network. + HTTP is not encrypted. Use loopback HTTP only for development on this device. + Connections to other devices require HTTPS. )} {error && {error}} diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 8078bf07935..6da20e63213 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -7093,7 +7093,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Before opening a WebSocket, the companion exchanges the token in an HTTP Authorization header for a short-lived, single-use upgrade ticket. The long-lived token is not included in the WebSocket URL or subprotocols. Older servers without ticket support must be updated; there is no credential-URL fallback.", "", - "Public endpoints require HTTPS. Literal private LAN and loopback HTTP addresses are accepted for development, with a plaintext-token warning. Mobile platform transport policies may still restrict cleartext networking; prefer HTTPS on devices. A phone's `localhost` refers to the phone, not your development computer.", + "All non-loopback endpoints—including private LAN, ULA, and link-local addresses—require HTTPS. HTTP is accepted only for `localhost`, IPv4 loopback (`127.0.0.0/8`), or IPv6 loopback (`[::1]`) for development, with a plaintext-token warning. A phone's `localhost` refers to the phone, not your development computer: use a trusted HTTPS endpoint to reach that computer from a device.", "", "## Develop with React Native Web", "", From 3c6f3e37dfc5dd5c2bb209f07b7d80184088942e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 04:45:41 +0000 Subject: [PATCH 74/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20refresh=20a?= =?UTF-8?q?gent=20catalogs=20with=20live=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reload agents within each abortable settings generation, reject stale responses, and keep removed next-turn modes blocked without changing delegated identity or live Stop/answer controls. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$344.63`_ --- .../mobile/src/screens/ConversationScreen.tsx | 24 +++--- .../mobile/src/screens/session.behavior.tsx | 79 +++++++++++++++++-- packages/mobile/src/useConversation.test.ts | 57 ++++++++++++- packages/mobile/src/useConversation.ts | 6 +- 4 files changed, 145 insertions(+), 21 deletions(-) diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index d87604b56d5..f946a19c81e 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -127,12 +127,18 @@ export function ConversationScreen(props: { const loadError = error ?? settingsError; const modelBlockReason = settings && options ? getModelBlockReason(settings, options.model) : null; + // The catalog includes non-selectable delegated agents, but excludes disabled ones. + const agentBlockReason = + settings && options && !settings.agents.some((agent) => agent.id === options.agentId) + ? "This agent is no longer available. Choose another mode or enable it on desktop." + : null; + const requestBlockReason = modelBlockReason ?? agentBlockReason; const settingsReady = ready && settings !== null && !settingsError; - const canAct = settingsReady && !modelBlockReason && !transcriptOnly; - const latestSettings = useRef({ options, modelBlockReason, transcriptOnly }); + const canAct = settingsReady && !requestBlockReason && !transcriptOnly; + const latestSettings = useRef({ options, requestBlockReason, transcriptOnly }); useEffect(() => { - latestSettings.current = { options, modelBlockReason, transcriptOnly }; - }, [options, modelBlockReason, transcriptOnly]); + latestSettings.current = { options, requestBlockReason, transcriptOnly }; + }, [options, requestBlockReason, transcriptOnly]); const running = ready && transcript.streaming; const actionDisabled = !ready || busy || (!running && (!canAct || !hasDraft || !options?.model)); // A live answer resolves the existing tool, not the next-turn model. Connection, @@ -261,13 +267,13 @@ export function ConversationScreen(props: { !latest.metadata?.partial ) return; - const { options, modelBlockReason, transcriptOnly } = latestSettings.current; + const { options, requestBlockReason, transcriptOnly } = latestSettings.current; // The answer is already durable and its form may disappear on tool-call-end. // Keep resume failures outside that form, and retry only resume, never the answer. setResumeMessageId(messageId); // Settings or policy can change while the answer is saved. Preserve recovery // while unavailable, but never resume with stale options or a prohibited route. - if (!options?.model || modelBlockReason || transcriptOnly) return; + if (!options?.model || requestBlockReason || transcriptOnly) return; try { const result = await props.client.workspace.resumeStream( { workspaceId: props.workspace.id, options }, @@ -311,7 +317,7 @@ export function ConversationScreen(props: { async function answer(toolCallId: string, answers: Record) { if (!ready) throw new Error("Reconnect before answering."); if (!canAnswer) - throw new Error(modelBlockReason ?? settingsError ?? "Wait for settings before answering."); + throw new Error(requestBlockReason ?? settingsError ?? "Wait for settings before answering."); if (pending.current) throw new Error("Another action is in progress."); if ( !answerMessage || @@ -479,9 +485,9 @@ export function ConversationScreen(props: { style={styles.composerWrap} onLayout={(event) => setComposerHeight(event.nativeEvent.layout.height)} > - {modelBlockReason && ( + {requestBlockReason && ( - {modelBlockReason} + {requestBlockReason} )} {actionError && ( diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index 7db7aba0888..40af1e5f50e 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -63,6 +63,12 @@ function fixture( let providers: SettingsData["providers"] = { anthropic: { isConfigured: true, isEnabled: true, apiKeySet: true, models: ["allowed"] }, }; + let agents: SettingsData["agents"] = [ + { id: "exec", name: "Exec", uiSelectable: true }, + { id: "explore", name: "Explore", uiSelectable: false }, + { id: "custom-worker", name: "custom-worker", uiSelectable: false }, + { id: "plan", name: "Plan", uiSelectable: true }, + ].map((agent) => ({ ...agent, scope: "built-in", subagentRunnable: true })); const configEvents: ReadableStreamDefaultController[] = []; const providerEvents: ReadableStreamDefaultController[] = []; let configRead = async () => config; @@ -119,11 +125,7 @@ function fixture( case "providers.getConfig": return providers; case "agents.list": - return [ - { id: "exec", name: "Exec", uiSelectable: true }, - { id: "explore", name: "Explore", uiSelectable: false }, - { id: "plan", name: "Plan", uiSelectable: true }, - ]; + return agents; case "workspace.onChat": { if ( !options.signal || @@ -215,6 +217,13 @@ function fixture( ); await act(async () => metadataEvents.at(-1)!.enqueue({ workspaceId: metadata.id, metadata })); }, + get agents() { + return agents; + }, + async updateAgents(next: SettingsData["agents"]) { + agents = next; + await act(async () => configEvents.at(-1)!.enqueue()); + }, setConfigRead(read: typeof configRead) { configRead = read; }, @@ -456,6 +465,62 @@ test("transcript-only pending questions are read-only while live Stop remains av } }); +test("live agent catalog removal updates the picker and blocks a remembered mode until re-enabled", async () => { + const view = fixture([answeredPartial()]); + await view.select("alpha"); + const enabled = view.agents; + fireEvent.click(view.getByRole("button", { name: "Choose mode" })); + fireEvent.click(view.getByRole("radio", { name: "Plan" })); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Keep this draft" } }); + await view.updateAgents(enabled.filter((agent) => agent.id !== "plan")); + expect(view.getByRole("button", { name: "Choose mode" }).textContent).toContain("plan"); + expect(view.getByRole("button", { name: "Send message" }).getAttribute("aria-disabled")).toBe( + "true" + ); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Send message" })); + expect(callCount(view, "sendMessage")).toBe(0); + expect(view.getByRole("alert")).toBeDefined(); + fireEvent.click(view.getByRole("button", { name: "Choose mode" })); + expect(view.queryByRole("radio", { name: "Plan" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Close" })); + await view.updateAgents(enabled); + expect(view.getByLabelText("Message")).toHaveProperty("value", "Keep this draft"); + expect(view.queryByRole("alert")).toBeNull(); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + expect(view.calls.find((call) => call.path === "workspace.sendMessage")?.input).toMatchObject({ + options: { agentId: "plan" }, + }); +}); + +test("a removed next-turn agent does not strand a live answer or Stop", async () => { + const view = fixture([ + { + type: "stream-start", + workspaceId: "alpha", + messageId: "question", + model, + historySequence: 1, + startTime: 1, + }, + question(), + { + type: "tool-call-execution-start", + workspaceId: "alpha", + messageId: "question", + toolCallId: "question", + timestamp: 1, + }, + ]); + await view.select("alpha"); + await view.updateAgents(view.agents.filter((agent) => agent.id !== "exec")); + await submitAnswer(view); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(0); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Interrupt agent" }))); + expect(callCount(view, "interruptStream")).toBe(1); +}); + test("failed credential clearing leaves the session usable and reconnectable before retrying disconnect", async () => { const view = fixture(); await view.select("alpha"); @@ -1176,7 +1241,9 @@ test("live route and provider changes re-evaluate policy without replacing the s expect(view.calls.find((call) => call.path === "workspace.sendMessage")?.input).toMatchObject({ options: { model: "anthropic:allowed" }, }); - expect(view.calls.filter((call) => call.path === "agents.list")).toHaveLength(1); + expect(view.calls.filter((call) => call.path === "agents.list")).toHaveLength( + view.calls.filter((call) => call.path === "config.getConfig").length + ); }); test("unavailable live settings prevent send and retain resume-only recovery after an answer is saved", async () => { diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts index 9287baee0f6..cd3f3951086 100644 --- a/packages/mobile/src/useConversation.test.ts +++ b/packages/mobile/src/useConversation.test.ts @@ -28,6 +28,7 @@ function fixture( getPolicy: () => Promise = async () => disabledPolicy, reads: { config?: () => Promise; + agents?: () => Promise; providers?: () => Promise; } = {} ) { @@ -110,7 +111,9 @@ function fixture( settingsRequests.push({ path: "providers", signal: options.signal! }); return reads.providers ? reads.providers() : {}; case "agents.list": - return []; + settingsOrder.push("agents.read"); + settingsRequests.push({ path: "agents", signal: options.signal! }); + return reads.agents ? reads.agents() : []; case "workspace.onChat": chatRequests.push(options.signal!); return new ReadableStream({ @@ -431,6 +434,50 @@ test("settings subscriptions precede reads and refresh privacy, routes and provi expect(view.providerSubscriptions[0].signal.aborted).toBe(true); }); +test("agent catalogs refresh with config/providers and stale catalog success or failure cannot win", async () => { + type Catalog = SettingsData["agents"]; + const enabled: Catalog = [ + { id: "plan", name: "Plan", uiSelectable: true, subagentRunnable: false, scope: "built-in" }, + ]; + let complete!: (value: Catalog) => void; + let fail!: (error: Error) => void; + let initial = true; + let catalog = enabled; + const view = fixture(undefined, { + agents: () => { + if (initial) { + initial = false; + return new Promise((resolve, reject) => { + complete = resolve; + fail = reject; + }); + } + return Promise.resolve(catalog); + }, + }); + await view.ready(); + expect(view.result.current.settings).toBeNull(); + const first = view.settingsRequests.find((request) => request.path === "agents")!; + await act(async () => view.configSubscriptions[0].events.enqueue()); + await waitFor(() => expect(view.result.current.settings?.agents).toEqual(enabled)); + expect(first.signal.aborted).toBe(true); + await act(async () => complete([])); + expect(view.result.current.settings?.agents).toEqual(enabled); + initial = true; + await act(async () => view.providerSubscriptions[0].events.enqueue()); + const staleFailure = fail; + catalog = []; + await act(async () => view.configSubscriptions[0].events.enqueue()); + await waitFor(() => expect(view.result.current.settings?.agents).toEqual([])); + await act(async () => staleFailure(new Error("old catalog failed"))); + expect(view.result.current.settingsError).toBeNull(); + expect(view.result.current.settings?.agents).toEqual([]); + catalog = enabled; + await act(async () => view.providerSubscriptions[0].events.enqueue()); + await waitFor(() => expect(view.result.current.settings?.agents).toEqual(enabled)); + expect(view.settingsRequests.filter((request) => request.path === "agents")).toHaveLength(5); +}); + test("a newer config event cancels a stale initial read rather than exposing old privacy settings", async () => { let resolve!: (config: SettingsData["config"]) => void; let first = true; @@ -458,7 +505,7 @@ test("a newer config event cancels a stale initial read rather than exposing old expect(view.result.current.settings?.config).toEqual(latest); }); -test.each(["config", "providers"] as const)( +test.each(["config", "providers", "agents"] as const)( "a failed %s refresh blocks settings until a later notification recovers", async (source) => { let failed = false; @@ -467,6 +514,10 @@ test.each(["config", "providers"] as const)( if (source === "config" && failed) throw new Error("unavailable"); return { agentAiDefaults: {} }; }, + agents: async () => { + if (source === "agents" && failed) throw new Error("unavailable"); + return []; + }, providers: async () => { if (source === "providers" && failed) throw new Error("unavailable"); return {}; @@ -475,7 +526,7 @@ test.each(["config", "providers"] as const)( await view.ready(); failed = true; const subscription = - source === "config" ? view.configSubscriptions[0] : view.providerSubscriptions[0]; + source === "providers" ? view.providerSubscriptions[0] : view.configSubscriptions[0]; await act(async () => subscription.events.enqueue()); await waitFor(() => expect(view.result.current.settingsError).not.toBeNull()); expect(view.result.current.error).toBeNull(); diff --git a/packages/mobile/src/useConversation.ts b/packages/mobile/src/useConversation.ts index 984c44bf010..162cd7e8a1a 100644 --- a/packages/mobile/src/useConversation.ts +++ b/packages/mobile/src/useConversation.ts @@ -68,10 +68,9 @@ export function useConversation( let settingsRequest: AbortController | null = null; async function subscribeSettings() { // Both subscriptions must be registered before reading privacy/routing settings. - const [configEvents, providerEvents, agents] = await Promise.all([ + const [configEvents, providerEvents] = await Promise.all([ client.config.onConfigChanged(undefined, { signal: settingsController.signal }), client.providers.onConfigChanged(undefined, { signal: settingsController.signal }), - client.agents.list({ workspaceId }, { signal: settingsController.signal }), ]); if (settingsController.signal.aborted) return; function refresh() { @@ -84,10 +83,11 @@ export function useConversation( setSettingsError(null); Promise.all([ client.config.getConfig(undefined, { signal: request.signal }), + client.agents.list({ workspaceId }, { signal: request.signal }), client.providers.getConfig(undefined, { signal: request.signal }), ]) .then( - ([config, providers]) => { + ([config, agents, providers]) => { if (!request.signal.aborted) setSettings({ config, providers, agents }); }, () => { From e478b03889c703d2d9243cb86cb199dd4e9f4338 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 04:52:03 +0000 Subject: [PATCH 75/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20enforce=20r?= =?UTF-8?q?untime=20policy=20during=20workspace=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load effective policy with subscription-before-read, fail-closed refreshes, cancellation, and connection ownership. Reuse the existing runtime-policy helper to gate local scratch versus default worktree creation, including blocked client versions. Keep policy lifetime separate from branch/create state so updates preserve drafts and ongoing mutation responses. Validation: four initial policy regressions were red before implementation; six focused policy cases and all owned forms now pass. With the package-pinned Bun 1.3.5, full mobile-check passes (155 tests, 1 existing live-server skip) and root static-check passes. Initial transport failures under Bun 1.2.15 passed unchanged under 1.3.5; no transport code changed. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$239.80`_ --- .../mobile/src/screens/CreateWorkspace.tsx | 99 ++++++ .../mobile/src/screens/forms.behavior.tsx | 329 +++++++++++++++++- 2 files changed, 422 insertions(+), 6 deletions(-) diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx index 2b61e54cc73..8a293c1aef9 100644 --- a/packages/mobile/src/screens/CreateWorkspace.tsx +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -9,6 +9,12 @@ import { Button, Field, Loading, Notice, Sheet } from "../components/Controls"; import { colors, layout, radii, spacing, typography } from "../theme"; import { linkedAbortController } from "../useConnection"; import { resolveWorkspaceCreationScope } from "../../../../src/common/utils/subProjects"; +import type { PolicyGetResponse } from "../../../../src/common/orpc/types"; +import { RUNTIME_MODE } from "../../../../src/common/types/runtime"; +import { isParsedRuntimeAllowedByPolicy } from "../../../../src/browser/utils/policyUi"; + +const POLICY_UNAVAILABLE_MESSAGE = + "Server policy is unavailable. Retry to reconnect before creating a workspace."; export function CreateWorkspace(props: { client: MobileClient; @@ -29,6 +35,12 @@ export function CreateWorkspace(props: { const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [policySnapshot, setPolicySnapshot] = useState<{ + client: MobileClient; + signal: AbortSignal; + response: PolicyGetResponse | null; + error: string | null; + } | null>(null); const pending = useRef(false); const controller = useRef(new AbortController()); const branchInput = useRef(null); @@ -63,6 +75,60 @@ export function CreateWorkspace(props: { return () => abort.abort(); }, [props.client, project, props.signal]); + useEffect(() => { + // Policy lifetime is separate from branch/create ownership: policy changes must not + // reset drafts or misattribute an already-started server mutation. + const lifetime = linkedAbortController(props.signal); + let request: AbortController | null = null; + function publish(response: PolicyGetResponse | null, error: string | null = null) { + if (!lifetime.signal.aborted) + setPolicySnapshot({ client: props.client, signal: props.signal, response, error }); + } + function unavailable() { + request?.abort(); + publish(null, POLICY_UNAVAILABLE_MESSAGE); + } + publish(null); + async function watch() { + const events = await props.client.policy.onChanged(undefined, { signal: lifetime.signal }); + if (lifetime.signal.aborted) { + await events.return?.(); + return; + } + function refresh() { + if (lifetime.signal.aborted) return; + request?.abort(); + const next = linkedAbortController(lifetime.signal); + request = next; + publish(null); + props.client.policy + .get(undefined, { signal: next.signal }) + .then( + (response) => { + if (!next.signal.aborted) publish(response); + }, + () => { + if (!next.signal.aborted) unavailable(); + } + ) + .finally(() => next.abort()); + } + // Listen first, and keep consuming invalidations while a read is pending. + refresh(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Notifications have no payload. + for await (const _ of events) { + if (lifetime.signal.aborted) return; + refresh(); + } + unavailable(); + } + if (!props.signal.aborted) watch().catch(unavailable); + return () => { + lifetime.abort(); + request?.abort(); + }; + }, [props.client, props.signal]); + function selectProject(path: string | null) { if (pending.current) return; setChoosingProject(false); @@ -84,9 +150,30 @@ export function CreateWorkspace(props: { projectsByPath.get(resolveWorkspaceCreationScope(project, projectsByPath).projectPath) ?.trusted === true; const repositoryUnsupported = project !== null && branches?.length === 0; + const policy = + policySnapshot?.client === props.client && policySnapshot.signal === props.signal + ? policySnapshot + : null; + const response = policy?.response; + const policyMessage = !response + ? (policy?.error ?? "Checking server policy before creating a workspace…") + : response.status.state === "blocked" + ? response.status.reason || + "Workspace creation is blocked by server policy. Contact your administrator." + : response.status.state === "enforced" && !response.policy + ? POLICY_UNAVAILABLE_MESSAGE + : !isParsedRuntimeAllowedByPolicy( + response.status.state === "enforced" ? response.policy : null, + { mode: project === null ? RUNTIME_MODE.LOCAL : RUNTIME_MODE.WORKTREE } + ) + ? project === null + ? "Scratch chats require the local runtime, which server policy blocks. Choose an allowed project or contact your administrator." + : "Server policy blocks project worktrees. Choose an allowed scratch chat or contact your administrator." + : null; const creationDisabled = !props.connected || props.signal.aborted || + policyMessage !== null || loading || (project !== null && (projectUnavailable || !projectTrusted || !branches?.length || !trunk.trim())); @@ -201,6 +288,18 @@ export function CreateWorkspace(props: { )} + {policyMessage && ( + + {policyMessage} + + )} {projectUnavailable && ( The selected project is no longer available. Choose another project to continue. diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index a90d50ba90f..2448383972f 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -301,6 +301,46 @@ test("sheet contents and footer do not dismiss it; the backdrop does, unless dis expect(dismissals).toBe(1); }); +type CreationPolicy = Awaited>; +const unrestrictedCreationPolicy: CreationPolicy = { + source: "none", + status: { state: "disabled" }, + policy: null, +}; + +function creationRuntimePolicy( + runtimes: NonNullable["runtimes"] +): CreationPolicy { + return { + source: "env", + status: { state: "enforced" }, + policy: { + policyFormatVersion: "0.1", + providerAccess: null, + runtimes, + mcp: { allowUserDefined: { stdio: true, remote: true } }, + }, + }; +} + +// Existing creation tests exercise trust, branches, and form submission under an unrestricted policy. +function createFormClient( + link: Parameters>[0] +): MobileClient { + return createORPCClient({ + call: async (path, input, options) => { + if (path.join(".") === "policy.get") return unrestrictedCreationPolicy; + if (path.join(".") === "policy.onChanged") + return new ReadableStream({ + start(controller) { + options.signal?.addEventListener("abort", () => controller.close(), { once: true }); + }, + }).values(); + return link.call(path, input, options); + }, + }); +} + test("workspace creation cannot be dismissed or submitted twice while the server is creating it", async () => { let resolve!: (value: { success: true; metadata: FrontendWorkspaceMetadata }) => void; const created = new Promise<{ success: true; metadata: FrontendWorkspaceMetadata }>((done) => { @@ -309,7 +349,7 @@ test("workspace creation cannot be dismissed or submitted twice while the server let calls = 0; let dismissals = 0; let selected: FrontendWorkspaceMetadata | undefined; - const client = createORPCClient({ + const client = createFormClient({ call: async (path) => { if (path.join(".") !== "workspace.createScratch") throw new Error("Unexpected procedure"); calls++; @@ -331,6 +371,11 @@ test("workspace creation cannot be dismissed or submitted twice while the server }} /> ); + await waitFor(() => + expect( + view.getByRole("button", { name: "Create scratch chat" }).getAttribute("aria-disabled") + ).not.toBe("true") + ); fireEvent.click(view.getByRole("button", { name: "Create scratch chat" })); fireEvent.click(view.getByRole("button", { name: "Create scratch chat" })); fireEvent.click(view.getByRole("button", { name: "Close" })); @@ -353,7 +398,7 @@ test.each([false, true])( resolve = done; }); const selected: FrontendWorkspaceMetadata[] = []; - const client = createORPCClient({ + const client = createFormClient({ call: async (path, input) => { const method = path.join("."); if (method === "projects.listBranches") @@ -399,6 +444,13 @@ test.each([false, true])( fireEvent.keyDown(finalInput, { key: "Enter", keyCode: 13 }); expect(calls).toHaveLength(0); view.rerender(renderForm(true)); + await waitFor(() => + expect( + view + .getByRole("button", { name: project ? "Create worktree" : "Create scratch chat" }) + .getAttribute("aria-disabled") + ).not.toBe("true") + ); if (project) { fireEvent.change(finalInput, { target: { value: " " } }); fireEvent.keyDown(finalInput, { key: "Enter", keyCode: 13 }); @@ -432,7 +484,7 @@ test.each(["restore", "reselect"])( "removed project blocks creation without losing drafts, then %s recovers", async (recovery) => { const calls: unknown[] = []; - const client = createORPCClient({ + const client = createFormClient({ call: async (path, input) => { if (path.join(".") === "projects.listBranches") return { branches: ["main"], recommendedTrunk: "main" }; @@ -511,7 +563,7 @@ test.each(["root", "subproject"])( async (kind) => { const calls: unknown[] = []; let branchReads = 0; - const client = createORPCClient({ + const client = createFormClient({ call: async (path, input) => { if (path.join(".") === "projects.listBranches") { branchReads++; @@ -620,7 +672,7 @@ test.each(["empty", "error"])( reject = no; }); const calls: string[] = []; - const client = createORPCClient({ + const client = createFormClient({ call: async (path) => { const method = path.join("."); if (method === "projects.listBranches") return result; @@ -676,7 +728,7 @@ test("late branch results cannot change a newly selected project's eligibility", }); const reads: Array = []; const calls: unknown[] = []; - const client = createORPCClient({ + const client = createFormClient({ call: async (path, input, options) => { if (path.join(".") === "projects.listBranches") { reads.push(options.signal); @@ -728,6 +780,271 @@ test("late branch results cannot change a newly selected project's eligibility", expect(calls[0]).toMatchObject({ projectPath: "/new", trunkBranch: "main" }); }); +function creationPolicyClient() { + const order: string[] = []; + const reads: AbortSignal[] = []; + const calls: Array<{ method: string; input: unknown }> = []; + const subscriptions: Array<{ signal?: AbortSignal; emit: () => void; close: () => void }> = []; + let read = async (): Promise => unrestrictedCreationPolicy; + let create = async (): Promise => ({ success: true, metadata: workspace }); + const client = createORPCClient({ + call: async (path, input, options) => { + const method = path.join("."); + if (method === "policy.onChanged") { + order.push(method); + return new ReadableStream({ + start(controller) { + let closed = false; + const close = () => { + if (!closed) { + closed = true; + controller.close(); + } + }; + subscriptions.push({ signal: options.signal, emit: () => controller.enqueue(), close }); + options.signal?.addEventListener("abort", close, { once: true }); + }, + }).values(); + } + if (method === "policy.get") { + if (!options.signal) throw new Error("Policy reads must be cancellable"); + order.push(method); + reads.push(options.signal); + return read(); + } + if (method === "projects.listBranches") + return { branches: ["main"], recommendedTrunk: "main" }; + calls.push({ method, input }); + return create(); + }, + }); + return { + client, + calls, + reads, + order, + subscriptions, + setRead: (value: typeof read) => { + read = value; + }, + setCreate: (value: typeof create) => { + create = value; + }, + }; +} + +function policyCreationForm( + client: MobileClient, + signal: AbortSignal, + onCreated: (value: FrontendWorkspaceMetadata) => void = () => {} +) { + return ( + {}} + onClose={() => {}} + onCreated={onCreated} + /> + ); +} + +test("creation policy subscribes before reading, fails closed, cancels stale reads, and heals on change", async () => { + const fixture = creationPolicyClient(); + let resolveOld!: (value: CreationPolicy) => void; + fixture.setRead( + () => + new Promise((resolve) => { + resolveOld = resolve; + }) + ); + const view = render(policyCreationForm(fixture.client, new AbortController().signal)); + const title = view.getByLabelText("Title (optional)"); + fireEvent.change(title, { target: { value: "Keep policy draft" } }); + const button = view.getByRole("button", { name: "Create scratch chat" }); + expect(button.getAttribute("aria-disabled")).toBe("true"); + fireEvent.keyDown(title, { key: "Enter", keyCode: 13 }); + expect(fixture.calls).toHaveLength(0); + await waitFor(() => expect(fixture.reads).toHaveLength(1)); + expect(fixture.order).toEqual(["policy.onChanged", "policy.get"]); + fixture.setRead(() => Promise.reject(new Error("policy unavailable"))); + await act(async () => { + fixture.subscriptions[0].emit(); + }); + await waitFor(() => expect(fixture.reads).toHaveLength(2)); + expect(fixture.reads[0].aborted).toBe(true); + await waitFor(() => expect(view.getByRole("alert")).toBeDefined()); + await act(async () => { + resolveOld(unrestrictedCreationPolicy); + }); + fireEvent.click(button); + fireEvent.keyDown(title, { key: "Enter", keyCode: 13 }); + expect(fixture.calls).toHaveLength(0); + fixture.setRead(async () => unrestrictedCreationPolicy); + await act(async () => { + fixture.subscriptions[0].emit(); + }); + await waitFor(() => expect(button.getAttribute("aria-disabled")).not.toBe("true")); + expect(view.getByDisplayValue("Keep policy draft")).toBeDefined(); + expect(view.queryByRole("alert")).toBeNull(); + await act(async () => { + fixture.subscriptions[0].close(); + }); + await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true")); + expect(view.getByRole("alert")).toBeDefined(); +}); + +test("live runtime policies gate only the selected creation path and version blocks gate both", async () => { + const fixture = creationPolicyClient(); + fixture.setRead(async () => creationRuntimePolicy(["worktree"])); + const view = render(policyCreationForm(fixture.client, new AbortController().signal)); + await waitFor(() => expect(fixture.reads).toHaveLength(1)); + await waitFor(() => expect(view.getByRole("alert")).toBeDefined()); + fireEvent.keyDown(view.getByLabelText("Title (optional)"), { key: "Enter", keyCode: 13 }); + expect(fixture.calls).toHaveLength(0); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "Example" })); + await waitFor(() => + expect( + view.getByRole("button", { name: "Create worktree" }).getAttribute("aria-disabled") + ).not.toBe("true") + ); + fireEvent.change(view.getByLabelText("Title (optional)"), { target: { value: "Policy draft" } }); + fireEvent.change(view.getByLabelText("Branch name (optional)"), { + target: { value: "keep-branch" }, + }); + fixture.setRead(async () => creationRuntimePolicy(["local"])); + await act(async () => { + fixture.subscriptions[0].emit(); + }); + await waitFor(() => + expect( + view.getByRole("button", { name: "Create worktree" }).getAttribute("aria-disabled") + ).toBe("true") + ); + fireEvent.keyDown(view.getByLabelText("Base branch"), { key: "Enter", keyCode: 13 }); + expect(fixture.calls).toHaveLength(0); + expect(view.getByDisplayValue("keep-branch")).toBeDefined(); + fixture.setRead(async () => ({ + source: "env", + status: { state: "blocked", reason: "minimum_client_version requires an update" }, + policy: null, + })); + await act(async () => { + fixture.subscriptions[0].emit(); + }); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain("minimum_client_version") + ); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.click(view.getByRole("button", { name: "Scratch chat" })); + fireEvent.keyDown(view.getByLabelText("Title (optional)"), { key: "Enter", keyCode: 13 }); + expect(fixture.calls).toHaveLength(0); + fixture.setRead(async () => creationRuntimePolicy(["local"])); + await act(async () => { + fixture.subscriptions[0].emit(); + }); + await waitFor(() => + expect( + view.getByRole("button", { name: "Create scratch chat" }).getAttribute("aria-disabled") + ).not.toBe("true") + ); + expect(view.getByDisplayValue("Policy draft")).toBeDefined(); + await act(async () => { + fireEvent.keyDown(view.getByLabelText("Title (optional)"), { key: "Enter", keyCode: 13 }); + }); + expect(fixture.calls).toEqual([ + { method: "workspace.createScratch", input: { title: "Policy draft" } }, + ]); +}); + +test.each([ + { source: "env", status: { state: "enforced" }, policy: null }, + { source: "env", status: { state: "blocked", reason: "" }, policy: null }, +] satisfies CreationPolicy[])( + "incomplete or blocked policy keeps creation disabled with an explanation: %j", + async (policy) => { + const fixture = creationPolicyClient(); + fixture.setRead(async () => policy); + const view = render(policyCreationForm(fixture.client, new AbortController().signal)); + await waitFor(() => expect(view.getByRole("alert")).toBeDefined()); + expect(view.getByRole("alert").textContent?.trim().length).toBeGreaterThan(0); + fireEvent.click(view.getByRole("button", { name: "Create scratch chat" })); + fireEvent.keyDown(view.getByLabelText("Title (optional)"), { key: "Enter", keyCode: 13 }); + expect(fixture.calls).toHaveLength(0); + } +); + +test("policy changes cannot reset an in-flight creation or misattribute its response", async () => { + const fixture = creationPolicyClient(); + let resolveCreate!: (value: unknown) => void; + fixture.setCreate( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }) + ); + const created: FrontendWorkspaceMetadata[] = []; + const view = render( + policyCreationForm(fixture.client, new AbortController().signal, (value) => { + created.push(value); + }) + ); + const button = view.getByRole("button", { name: "Create scratch chat" }); + await waitFor(() => expect(button.getAttribute("aria-disabled")).not.toBe("true")); + fireEvent.change(view.getByLabelText("Title (optional)"), { + target: { value: "Original intent" }, + }); + fireEvent.click(button); + expect(fixture.calls).toHaveLength(1); + fixture.setRead(async () => creationRuntimePolicy(["worktree"])); + await act(async () => { + fixture.subscriptions[0].emit(); + }); + await waitFor(() => expect(view.getByRole("alert")).toBeDefined()); + fireEvent.click(button); + fireEvent.click(view.getByRole("button", { name: "Choose project" })); + fireEvent.keyDown(view.getByLabelText("Title (optional)"), { key: "Enter", keyCode: 13 }); + expect(fixture.calls).toHaveLength(1); + await act(async () => { + resolveCreate({ success: true, metadata: workspace }); + }); + expect(created).toEqual([workspace]); + expect(fixture.calls[0].input).toEqual({ title: "Original intent" }); +}); + +test("replacing a creation connection cancels policy work and rejects the old snapshot", async () => { + const old = creationPolicyClient(); + let resolveOld!: (value: CreationPolicy) => void; + old.setRead( + () => + new Promise((resolve) => { + resolveOld = resolve; + }) + ); + const first = new AbortController(); + const view = render(policyCreationForm(old.client, first.signal)); + await waitFor(() => expect(old.reads).toHaveLength(1)); + const next = creationPolicyClient(); + next.setRead(async () => creationRuntimePolicy([])); + view.rerender(policyCreationForm(next.client, new AbortController().signal)); + await waitFor(() => expect(next.reads).toHaveLength(1)); + expect(old.subscriptions[0].signal?.aborted).toBe(true); + expect(old.reads[0].aborted).toBe(true); + await act(async () => { + resolveOld(unrestrictedCreationPolicy); + }); + expect( + view.getByRole("button", { name: "Create scratch chat" }).getAttribute("aria-disabled") + ).toBe("true"); + fireEvent.keyDown(view.getByLabelText("Title (optional)"), { key: "Enter", keyCode: 13 }); + expect(next.calls).toHaveLength(0); + view.unmount(); + expect(next.subscriptions[0].signal?.aborted).toBe(true); +}); + const pickerValue: ChatSettings = { agentId: "exec", model: "local:one", From cd97460af8eef32dd0874481447c71f438314e9d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 05:00:50 +0000 Subject: [PATCH 76/84] =?UTF-8?q?=F0=9F=A4=96=20perf(mobile):=20coalesce?= =?UTF-8?q?=20streamed=20text=20display=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch only ordered text/reasoning deltas at a bounded display cadence, flush immediate events and history merges, and discard stale pending work on lifetime changes. Verify the real Markdown render count and responsive Stop/input controls. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$377.54`_ --- packages/mobile/src/displayTestClock.ts | 40 +++ .../mobile/src/screens/session.behavior.tsx | 15 +- .../mobile/src/screens/streaming.behavior.tsx | 126 +++++++++ packages/mobile/src/screens/streaming.test.ts | 30 +++ .../src/screens/streamingTestProfiler.tsx | 15 ++ packages/mobile/src/useConversation.test.ts | 244 +++++++++++++++++- packages/mobile/src/useConversation.ts | 49 +++- src/constants/streaming.ts | 5 + 8 files changed, 512 insertions(+), 12 deletions(-) create mode 100644 packages/mobile/src/displayTestClock.ts create mode 100644 packages/mobile/src/screens/streaming.behavior.tsx create mode 100644 packages/mobile/src/screens/streaming.test.ts create mode 100644 packages/mobile/src/screens/streamingTestProfiler.tsx diff --git a/packages/mobile/src/displayTestClock.ts b/packages/mobile/src/displayTestClock.ts new file mode 100644 index 00000000000..4a125dd7e64 --- /dev/null +++ b/packages/mobile/src/displayTestClock.ts @@ -0,0 +1,40 @@ +import { MOBILE_STREAM_DISPLAY_BATCH_MS } from "../../../src/constants/streaming"; + +/** Hold only the display throttle; network, React and input timers remain real. */ +export function createDisplayTestClock() { + const set = globalThis.setTimeout; + const clear = globalThis.clearTimeout; + const pending = new Map, () => void>(); + // A timer spy makes Testing Library assume Jest fake timers are installed. + globalThis.setTimeout = Object.assign( + (callback: Parameters[0], delay?: number, ...args: unknown[]) => { + if (delay !== MOBILE_STREAM_DISPLAY_BATCH_MS) return set(callback, delay, ...args); + const handle = set(() => undefined, 60_000); + pending.set(handle, () => callback(...args)); + return handle; + }, + set + ); + globalThis.clearTimeout = (handle) => { + for (const timer of pending.keys()) if (timer === handle) pending.delete(timer); + clear(handle as Parameters[0]); + }; + return { + get pending() { + return pending.size; + }, + flush() { + for (const [timer, callback] of [...pending]) { + clear(timer); + pending.delete(timer); + callback(); + } + }, + [Symbol.dispose]() { + globalThis.setTimeout = set; + globalThis.clearTimeout = clear; + for (const timer of pending.keys()) clear(timer); + pending.clear(); + }, + }; +} diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index 40af1e5f50e..626bc05c89a 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -67,6 +67,7 @@ function fixture( { id: "exec", name: "Exec", uiSelectable: true }, { id: "explore", name: "Explore", uiSelectable: false }, { id: "custom-worker", name: "custom-worker", uiSelectable: false }, + { id: "scout", name: "Scout", uiSelectable: true }, { id: "plan", name: "Plan", uiSelectable: true }, ].map((agent) => ({ ...agent, scope: "built-in", subagentRunnable: true })); const configEvents: ReadableStreamDefaultController[] = []; @@ -470,10 +471,10 @@ test("live agent catalog removal updates the picker and blocks a remembered mode await view.select("alpha"); const enabled = view.agents; fireEvent.click(view.getByRole("button", { name: "Choose mode" })); - fireEvent.click(view.getByRole("radio", { name: "Plan" })); + fireEvent.click(view.getByRole("radio", { name: "Scout" })); fireEvent.change(view.getByLabelText("Message"), { target: { value: "Keep this draft" } }); - await view.updateAgents(enabled.filter((agent) => agent.id !== "plan")); - expect(view.getByRole("button", { name: "Choose mode" }).textContent).toContain("plan"); + await view.updateAgents(enabled.filter((agent) => agent.id !== "scout")); + expect(view.getByRole("button", { name: "Choose mode" }).textContent).toContain("scout"); expect(view.getByRole("button", { name: "Send message" }).getAttribute("aria-disabled")).toBe( "true" ); @@ -482,14 +483,14 @@ test("live agent catalog removal updates the picker and blocks a remembered mode expect(callCount(view, "sendMessage")).toBe(0); expect(view.getByRole("alert")).toBeDefined(); fireEvent.click(view.getByRole("button", { name: "Choose mode" })); - expect(view.queryByRole("radio", { name: "Plan" })).toBeNull(); + expect(view.queryByRole("radio", { name: "Scout" })).toBeNull(); fireEvent.click(view.getByRole("button", { name: "Close" })); await view.updateAgents(enabled); expect(view.getByLabelText("Message")).toHaveProperty("value", "Keep this draft"); expect(view.queryByRole("alert")).toBeNull(); await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); expect(view.calls.find((call) => call.path === "workspace.sendMessage")?.input).toMatchObject({ - options: { agentId: "plan" }, + options: { agentId: "scout" }, }); }); @@ -513,7 +514,9 @@ test("a removed next-turn agent does not strand a live answer or Stop", async () }, ]); await view.select("alpha"); - await view.updateAgents(view.agents.filter((agent) => agent.id !== "exec")); + fireEvent.click(view.getByRole("button", { name: "Choose mode" })); + fireEvent.click(view.getByRole("radio", { name: "Scout" })); + await view.updateAgents(view.agents.filter((agent) => agent.id !== "scout")); await submitAnswer(view); expect(callCount(view, "answerAskUserQuestion")).toBe(1); expect(callCount(view, "resumeStream")).toBe(0); diff --git a/packages/mobile/src/screens/streaming.behavior.tsx b/packages/mobile/src/screens/streaming.behavior.tsx new file mode 100644 index 00000000000..b6a94562d2d --- /dev/null +++ b/packages/mobile/src/screens/streaming.behavior.tsx @@ -0,0 +1,126 @@ +import { markdownUpdates } from "./streamingTestProfiler"; +import { afterEach, expect, test } from "bun:test"; +import { useState } from "react"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { createORPCClient } from "@orpc/client"; +import type { MobileClient } from "../api"; +import type { WorkspaceChatMessage } from "../transcript"; +import { ConversationScreen } from "./ConversationScreen"; +import { createDisplayTestClock } from "../displayTestClock"; +import { EMPTY_DRAFT } from "../draft"; +afterEach(cleanup); + +test("120 separately delivered deltas render Markdown once per display flush while input and Stop remain immediate", async () => { + using clock = createDisplayTestClock(); + let stream!: ReadableStreamDefaultController; + let interruptions = 0; + const client = createORPCClient({ + call: async (path, _input, options) => { + switch (path.join(".")) { + case "config.getConfig": + return { agentAiDefaults: {}, defaultModel: "anthropic:claude-sonnet-4-5" }; + case "providers.getConfig": + return { anthropic: { isConfigured: true, isEnabled: true, apiKeySet: true } }; + case "agents.list": + return [{ id: "exec", name: "Exec", uiSelectable: true }]; + case "policy.get": + return { source: "none", status: { state: "disabled" }, policy: null }; + case "config.onConfigChanged": + case "providers.onConfigChanged": + case "policy.onChanged": + return new ReadableStream({ + start(controller) { + options.signal?.addEventListener("abort", () => controller.close(), { once: true }); + }, + }).values(); + case "workspace.onChat": + return new ReadableStream({ + start(controller) { + stream = controller; + options.signal?.addEventListener("abort", () => controller.close(), { once: true }); + controller.enqueue({ + type: "stream-start", + workspaceId: "w", + messageId: "live", + historySequence: 1, + startTime: 1, + model: "anthropic:claude-sonnet-4-5", + }); + controller.enqueue({ type: "caught-up" }); + }, + }).values(); + case "workspace.interruptStream": + interruptions++; + stream.enqueue({ + type: "stream-abort", + workspaceId: "w", + messageId: "live", + abortReason: "user", + }); + return { success: true }; + default: + throw new Error(`Unexpected ${path.join(".")}`); + } + }, + }); + const lifetime = new AbortController(); + function Screen() { + const [draft, setDraft] = useState(EMPTY_DRAFT); + return ( + {}} + onBack={() => {}} + onSettings={() => {}} + onChanges={() => {}} + selection={null} + onSelectionChange={() => {}} + draft={draft} + onDraftChange={setDraft} + /> + ); + } + const view = render(); + await view.findByRole("button", { name: "Interrupt agent" }); + const seed = "An existing paragraph. ".repeat(200); + const emit = (delta: string) => + act(async () => + stream.enqueue({ + type: "stream-delta", + workspaceId: "w", + messageId: "live", + delta, + tokens: 1, + timestamp: 2, + }) + ); + await emit(seed); + act(() => clock.flush()); + const before = markdownUpdates.count; + for (let index = 0; index < 120; index++) await emit("x"); + expect(markdownUpdates.count - before).toBeLessThanOrEqual(1); + expect(clock.pending).toBe(1); + act(() => clock.flush()); + expect(markdownUpdates.count - before).toBe(1); + expect(view.getByText(seed + "x".repeat(120))).toBeDefined(); + await emit("stop-tail"); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Responsive draft" } }); + expect(view.getByLabelText("Message")).toHaveProperty("value", "Responsive draft"); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Interrupt agent" }))); + expect(interruptions).toBe(1); + expect(clock.pending).toBe(0); + expect(view.getByText(seed + "x".repeat(120) + "stop-tail")).toBeDefined(); + expect(view.queryByRole("button", { name: "Interrupt agent" })).toBeNull(); + expect(view.getByLabelText("Message")).toHaveProperty("value", "Responsive draft"); +}); diff --git a/packages/mobile/src/screens/streaming.test.ts b/packages/mobile/src/screens/streaming.test.ts new file mode 100644 index 00000000000..7976b91e9f5 --- /dev/null +++ b/packages/mobile/src/screens/streaming.test.ts @@ -0,0 +1,30 @@ +import { test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +test("streaming display performance and control boundaries", async () => { + const child = Bun.spawn( + [ + process.execPath, + "test", + "--preload", + "./src/screens/formTestDom.ts", + "--preload", + "./src/screens/formTestPlatform.ts", + "--preload", + "./src/screens/streamingTestProfiler.tsx", + "./src/screens/streaming.behavior.tsx", + ], + { cwd: fileURLToPath(new URL("../../", import.meta.url)), stdout: "pipe", stderr: "pipe" } + ); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) + throw new Error(`Streaming display tests failed (${code}):\n${stdout}\n${stderr}`); + } finally { + if (child.exitCode === null) child.kill(); + } +}, 30_000); diff --git a/packages/mobile/src/screens/streamingTestProfiler.tsx b/packages/mobile/src/screens/streamingTestProfiler.tsx new file mode 100644 index 00000000000..0dd519eef9a --- /dev/null +++ b/packages/mobile/src/screens/streamingTestProfiler.tsx @@ -0,0 +1,15 @@ +import { mock } from "bun:test"; +import { Profiler } from "react"; +import type { ComponentProps } from "react"; +import { Markdown } from "../components/Markdown"; + +// Profile the real parser/render tree; no production counters or mocked Markdown. +export const markdownUpdates = { count: 0 }; +const RealMarkdown = Markdown; +mock.module("../components/Markdown", () => ({ + Markdown: (props: ComponentProps) => ( + markdownUpdates.count++}> + + + ), +})); diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts index cd3f3951086..4cd5ad94335 100644 --- a/packages/mobile/src/useConversation.test.ts +++ b/packages/mobile/src/useConversation.test.ts @@ -2,6 +2,7 @@ import "./testDom"; import { afterEach, expect, test } from "bun:test"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { createORPCClient } from "@orpc/client"; +import { createDisplayTestClock } from "./displayTestClock"; import type { MobileClient } from "./api"; import { getVisibleMessages } from "./transcript"; import type { WorkspaceChatMessage } from "./transcript"; @@ -11,7 +12,10 @@ import type { SettingsData } from "./settings"; afterEach(cleanup); -function message(sequence: number, text = `message ${sequence}`): WorkspaceChatMessage { +function message( + sequence: number, + text = `message ${sequence}` +): Extract { return { type: "message", id: String(sequence), @@ -119,7 +123,19 @@ function fixture( return new ReadableStream({ start(controller) { eventController = controller; - options.signal?.addEventListener("abort", () => controller.close(), { once: true }); + options.signal?.addEventListener( + "abort", + () => { + if (controller.desiredSize !== null) { + try { + controller.close(); + } catch { + /* The test may have closed the stream first. */ + } + } + }, + { once: true } + ); }, }).values(); case "workspace.history.loadMore": @@ -169,6 +185,9 @@ function fixture( }); await waitFor(() => expect(view.result.current.transcript.caughtUp).toBe(true)); }, + async disconnect() { + await act(async () => eventController.close()); + }, async emit(event: WorkspaceChatMessage, afterEnqueue?: () => void) { await act(async () => { eventController.enqueue(event); @@ -178,6 +197,225 @@ function fixture( }; } +const displayStart: WorkspaceChatMessage = { + type: "stream-start", + workspaceId: "workspace", + messageId: "live", + model: "test:model", + historySequence: 11, + startTime: 1, +}; +function displayDelta( + delta: string, + type: "stream-delta" | "reasoning-delta" = "stream-delta", + messageId = "live" +): WorkspaceChatMessage { + return { type, workspaceId: "workspace", messageId, delta, tokens: 1, timestamp: 2 }; +} + +test("display batching retains mixed IDs and text/reasoning order across immediate tool boundaries", async () => { + using clock = createDisplayTestClock(); + const view = fixture(); + await view.ready(); + await view.emit(displayStart); + const before = view.result.current.transcript; + await view.emit(displayDelta("Think ", "reasoning-delta")); + await view.emit(displayDelta("ignored", "stream-delta", "stale")); + await view.emit(displayDelta("first", "reasoning-delta")); + await view.emit(displayDelta("Answer")); + expect(view.result.current.transcript).toBe(before); + expect(clock.pending).toBe(1); + await view.emit({ + type: "tool-call-start", + workspaceId: "workspace", + messageId: "live", + toolCallId: "question", + toolName: "ask_user_question", + args: {}, + timestamp: 3, + tokens: 1, + }); + expect(view.result.current.transcript.messages.at(-1)?.parts).toMatchObject([ + { type: "reasoning", text: "Think first" }, + { type: "text", text: "Answer" }, + { type: "dynamic-tool", toolCallId: "question" }, + ]); + expect(clock.pending).toBe(0); + await view.emit(displayDelta("After")); + act(() => clock.flush()); + expect(view.result.current.transcript.messages.at(-1)?.parts.at(-1)).toMatchObject({ + type: "text", + text: "After", + }); +}); + +test("new stream identity and immediate policy updates are not delayed by the display throttle", async () => { + using clock = createDisplayTestClock(); + let policy = disabledPolicy; + const view = fixture(() => Promise.resolve(policy)); + await view.ready(); + await view.emit(displayStart); + await view.emit(displayDelta("old tail")); + await view.emit({ ...displayStart, messageId: "next", historySequence: 12 }); + expect(clock.pending).toBe(0); + expect(view.result.current.transcript.streamingMessageId).toBe("next"); + expect( + view.result.current.transcript.messages.find((message) => message.id === "live")?.parts[0] + ).toMatchObject({ text: "old tail" }); + await view.emit(displayDelta("new tail", "stream-delta", "next")); + policy = { source: "env", status: { state: "blocked", reason: "Policy changed" }, policy: null }; + await act(async () => view.policySubscriptions[0].events.enqueue()); + await waitFor(() => expect(view.result.current.settings?.policy?.status.state).toBe("blocked")); + expect(clock.pending).toBe(1); + act(() => clock.flush()); + expect(view.result.current.transcript.messages.at(-1)?.parts[0]).toMatchObject({ + text: "new tail", + }); +}); + +test("restore and disconnect flush pending text immediately; deleting history cannot be undone by a timer or page", async () => { + using clock = createDisplayTestClock(); + const view = fixture(); + await view.ready(); + await view.emit(displayStart); + await view.emit(displayDelta("Before restore")); + await view.emit({ type: "restore-to-input", workspaceId: "workspace", text: "draft" }); + expect(view.restored).toHaveLength(1); + expect(view.result.current.transcript.messages.at(-1)?.parts[0]).toMatchObject({ + text: "Before restore", + }); + let page!: Promise; + act(() => { + page = view.result.current.loadOlder(); + }); + await view.emit(displayDelta(" pending")); + await view.emit({ type: "delete", historySequences: [11] }); + expect(clock.pending).toBe(0); + await act(async () => { + view.complete({ + messages: [{ ...message(11), id: "live" }], + hasOlder: false, + nextCursor: null, + }); + await page; + }); + act(() => clock.flush()); + expect(view.result.current.transcript.messages.some((row) => row.id === "live")).toBe(false); + await view.emit(displayStart); + await view.emit(displayDelta("Keep on disconnect")); + await view.disconnect(); + expect(view.result.current.error).not.toBeNull(); + expect(view.result.current.transcript.messages.at(-1)?.parts[0]).toMatchObject({ + text: "Keep on disconnect", + }); + expect(clock.pending).toBe(0); +}); + +test.each(["stream-abort", "stream-end", "stream-metadata"] as const)( + "%s flushes queued deltas synchronously", + async (type) => { + using clock = createDisplayTestClock(); + const view = fixture(); + await view.ready(); + await view.emit(displayStart); + await view.emit(displayDelta("tail")); + if (type === "stream-end") + await view.emit({ + type, + workspaceId: "workspace", + messageId: "live", + metadata: { model: "test:model" }, + parts: [{ type: "text", text: "final" }], + }); + else if (type === "stream-abort") + await view.emit({ type, workspaceId: "workspace", messageId: "live", abortReason: "user" }); + else + await view.emit({ + type, + workspaceId: "workspace", + messageId: "live", + metadata: { + model: "test:fallback", + metadataModel: "test:fallback", + contextWindowTokens: null, + routedThroughGateway: false, + routeProvider: null, + }, + }); + expect(clock.pending).toBe(0); + expect(view.result.current.transcript.messages.at(-1)?.parts[0]).toMatchObject({ + text: type === "stream-end" ? "final" : "tail", + }); + } +); + +test.each(["workspace", "connection", "abort", "unmount"] as const)( + "%s discards queued display deltas and its timer", + async (change) => { + using clock = createDisplayTestClock(); + const view = fixture(); + await view.ready(); + await view.emit(displayStart); + await view.emit(displayDelta("stale")); + expect(clock.pending).toBe(1); + if (change === "unmount") view.unmount(); + else if (change === "abort") act(() => view.lifetime.abort()); + else + view.rerender({ + workspaceId: change === "workspace" ? "new" : "workspace", + signal: change === "connection" ? new AbortController().signal : view.lifetime.signal, + }); + expect(clock.pending).toBe(0); + act(() => clock.flush()); + if (change !== "unmount") + expect( + view.result.current.transcript.messages.some((message) => + message.parts.some((part) => part.type === "text" && part.text === "stale") + ) + ).toBe(false); + } +); + +test("a paused display timer cannot accumulate an unbounded delta queue", async () => { + using clock = createDisplayTestClock(); + const view = fixture(); + await view.ready(); + await view.emit(displayStart); + for (let index = 0; index < 1100; index++) await view.emit(displayDelta("x")); + const displayed = view.result.current.transcript.messages.at(-1)?.parts[0]; + expect(displayed?.type === "text" ? displayed.text.length : 0).toBeGreaterThan(0); + expect(clock.pending).toBeLessThanOrEqual(1); + act(() => clock.flush()); + expect(view.result.current.transcript.messages.at(-1)?.parts[0]).toMatchObject({ + text: "x".repeat(1100), + }); +}); + +test("history pagination incorporates pending live deltas before merging older rows", async () => { + using clock = createDisplayTestClock(); + const view = fixture(); + await view.ready(); + await view.emit(displayStart); + let page!: Promise; + act(() => { + page = view.result.current.loadOlder(); + }); + await view.emit(displayDelta("live text")); + await act(async () => { + view.complete({ messages: [message(1)], hasOlder: false, nextCursor: null }); + await page; + }); + expect(clock.pending).toBe(0); + expect(view.result.current.transcript.messages.map((message) => message.id)).toEqual([ + "1", + "10", + "live", + ]); + expect(view.result.current.transcript.messages.at(-1)?.parts[0]).toMatchObject({ + text: "live text", + }); +}); + test("restore events use the latest workspace callback once without resubscribing or replaying queue snapshots", async () => { const view = fixture(); await view.ready(); @@ -437,7 +675,7 @@ test("settings subscriptions precede reads and refresh privacy, routes and provi test("agent catalogs refresh with config/providers and stale catalog success or failure cannot win", async () => { type Catalog = SettingsData["agents"]; const enabled: Catalog = [ - { id: "plan", name: "Plan", uiSelectable: true, subagentRunnable: false, scope: "built-in" }, + { id: "scout", name: "Scout", uiSelectable: true, subagentRunnable: false, scope: "global" }, ]; let complete!: (value: Catalog) => void; let fail!: (error: Error) => void; diff --git a/packages/mobile/src/useConversation.ts b/packages/mobile/src/useConversation.ts index 162cd7e8a1a..f1c114006df 100644 --- a/packages/mobile/src/useConversation.ts +++ b/packages/mobile/src/useConversation.ts @@ -1,6 +1,10 @@ import { useEffect, useRef, useState } from "react"; import type { MobileClient } from "./api"; -import { applyChatEvent, createTranscriptState } from "./transcript"; +import { applyChatEvent, createTranscriptState, type WorkspaceChatMessage } from "./transcript"; +import { + MOBILE_STREAM_DISPLAY_BATCH_MS, + MOBILE_STREAM_MAX_PENDING_DELTAS, +} from "../../../src/constants/streaming"; import type { SettingsData } from "./settings"; import type { RestoredInput } from "./draft"; import { linkedAbortController } from "./useConnection"; @@ -23,6 +27,7 @@ export function useConversation( const [owner, setOwner] = useState(() => ({ client, workspaceId, signal })); const [loadingOlder, setLoadingOlder] = useState(false); const [historyError, setHistoryError] = useState(null); + const displayBatch = useRef<{ take: () => WorkspaceChatMessage[] } | null>(null); const historyRequest = useRef(null); type HistoryCursor = NonNullable< Parameters[0]["cursor"] @@ -40,6 +45,32 @@ export function useConversation( setHistoryError(null); historyCursor.current = null; if (signal.aborted) return; + let pendingDeltas: WorkspaceChatMessage[] = []; + let displayTimer: ReturnType | null = null; + const take = () => { + if (displayTimer !== null) clearTimeout(displayTimer); + displayTimer = null; + const events = pendingDeltas; + pendingDeltas = []; + return events; + }; + const batch = { take }; + displayBatch.current = batch; + const flush = (event?: WorkspaceChatMessage) => { + const events = take(); + if (event) events.push(event); + if (!controller.signal.aborted && events.length > 0) + setTranscript((current) => events.reduce(applyChatEvent, current)); + }; + controller.signal.addEventListener( + "abort", + () => { + take(); + if (displayBatch.current === batch) displayBatch.current = null; + }, + { once: true } + ); + async function subscribePolicy() { // Subscribe before the initial read so changes during that read are not lost. const events = await client.policy.onChanged(undefined, { signal: controller.signal }); @@ -123,9 +154,19 @@ export function useConversation( ); for await (const event of events) { if (controller.signal.aborted) return; + // This timer throttles display work only. Retain original ordered events; + // every control/tool boundary flushes immediately, not at the next frame. + if (event.type === "stream-delta" || event.type === "reasoning-delta") { + pendingDeltas.push(event); + if (pendingDeltas.length >= MOBILE_STREAM_MAX_PENDING_DELTAS) flush(); + else if (displayTimer === null) + displayTimer = setTimeout(() => flush(), MOBILE_STREAM_DISPLAY_BATCH_MS); + continue; + } // This is a one-shot queue handoff, not replayable transcript state. Consume // it here so React rerenders cannot restore it twice or restart the socket. if (event.type === "restore-to-input") { + flush(); if (event.workspaceId === workspaceId) restore.current?.(event); continue; } @@ -136,7 +177,7 @@ export function useConversation( historyCursor.current = null; setLoadingOlder(false); } - setTranscript((current) => applyChatEvent(current, event)); + flush(event); } if (!controller.signal.aborted) throw new Error( @@ -144,6 +185,7 @@ export function useConversation( ); } subscribe().catch((cause: unknown) => { + flush(); if (!controller.signal.aborted) setError( cause instanceof Error @@ -193,10 +235,11 @@ export function useConversation( ); if (controller.signal.aborted) return; historyCursor.current = page.nextCursor; + const queuedDeltas = displayBatch.current?.take() ?? []; setTranscript((current) => { // Pages are historical snapshots; never replay old stream lifecycle events // over the current live turn or replace a newer copy of an existing row. - let next = current; + let next = queuedDeltas.reduce(applyChatEvent, current); for (const event of page.messages) { if ( event.type === "message" && diff --git a/src/constants/streaming.ts b/src/constants/streaming.ts index 8745aa8d616..c990bf872c4 100644 --- a/src/constants/streaming.ts +++ b/src/constants/streaming.ts @@ -5,6 +5,11 @@ // startup/streaming flags settle on adjacent renders. export const WORKSPACE_STREAMING_STATUS_TRANSITION_MS = 150; +// Mobile batches only append deltas before rendering Markdown; control events flush immediately. +export const MOBILE_STREAM_DISPLAY_BATCH_MS = 33; +// Background timers can be throttled; cap pending event count without dropping text. +export const MOBILE_STREAM_MAX_PENDING_DELTAS = 512; + /** * Average character-per-token estimate used to convert tokens-per-second (from * the streaming TPS calculator) into characters-per-second (consumed by the From fd0a60cb3797cb445befd99dee46f4db2888c526 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 05:51:33 +0000 Subject: [PATCH 77/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20preserve=20?= =?UTF-8?q?header-like=20lines=20inside=20diff=20hunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict filename lookup and header filtering to each file's pre-hunk section so source lines beginning with -- or ++ remain visible and counted. Cover multiple hunks, header suppression, and metadata-only changes in the UI. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$1600.29`_ --- packages/mobile/src/screens/ChangesScreen.tsx | 12 +++- .../mobile/src/screens/forms.behavior.tsx | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/mobile/src/screens/ChangesScreen.tsx b/packages/mobile/src/screens/ChangesScreen.tsx index 0d2e0d2cdbc..abf8d66c86b 100644 --- a/packages/mobile/src/screens/ChangesScreen.tsx +++ b/packages/mobile/src/screens/ChangesScreen.tsx @@ -110,16 +110,22 @@ function ProjectDiff(props: { data: Extract { const lines = file.trimEnd().split("\n"); - const newPath = lines.find((line) => line.startsWith("+++ "))?.slice(4); + // Git's +/- markers can make source lines resemble file headers inside a hunk. + const firstHunk = lines.findIndex((line) => line.startsWith("@@ ")); + const headers = firstHunk < 0 ? lines : lines.slice(0, firstHunk); + const newPath = headers.find((line) => line.startsWith("+++ "))?.slice(4); // Deletions have no new-side path; retain the old filename before hiding diff headers. const filename = (newPath === "/dev/null" - ? lines + ? headers .find((line) => line.startsWith("--- ")) ?.slice(4) .replace(/^a\//, "") : newPath?.replace(/^b\//, "")) ?? lines[0].replace(/^diff --git /, ""); - const content = lines.filter((line) => !/^(diff --git |index |--- |\+\+\+ )/.test(line)); + const content = lines.filter( + (line, index) => + index >= headers.length || !/^(diff --git |index |--- |\+\+\+ )/.test(line) + ); const additions = content.filter((line) => line.startsWith("+")).length; const deletions = content.filter((line) => line.startsWith("-")).length; return ( diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 2448383972f..10efa23f337 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -120,6 +120,62 @@ test("changes keep distinct deleted paths while additions use the new-side path" expect(view.getByText("+added")).toBeDefined(); }); +test("changes preserve header-like source lines inside every hunk and count them", async () => { + const diff = [ + "diff --git a/markers.txt b/markers.txt", + "index 1234567..89abcde 100644", + "--- a/markers.txt", + "+++ b/markers.txt", + "@@ -1,2 +1,2 @@", + "--- old comment", + "+++ new marker", + " unchanged", + "@@ -10 +10 @@", + "--- second old comment", + "+++ second new marker", + "diff --git a/script.sh b/script.sh", + "old mode 100644", + "new mode 100755", + "", + ].join("\n"); + const client = createORPCClient({ + call: async () => [ + { + projectName: "Project", + projectPath: "/project", + success: true, + data: { diff, truncated: false }, + }, + ], + }); + const view = render( + {}} + onBack={() => {}} + /> + ); + await waitFor(() => expect(view.getByText("markers.txt")).toBeDefined()); + for (const line of [ + "--- old comment", + "+++ new marker", + "--- second old comment", + "+++ second new marker", + ]) { + expect(view.getByText(line)).toBeDefined(); + } + expect(view.getByText("+2")).toBeDefined(); + expect(view.getByText("−2")).toBeDefined(); + expect(view.queryByText("--- a/markers.txt")).toBeNull(); + expect(view.queryByText("+++ b/markers.txt")).toBeNull(); + expect(view.queryByText("index 1234567..89abcde 100644")).toBeNull(); + expect(view.getByText("old mode 100644")).toBeDefined(); + expect(view.getByText("new mode 100755")).toBeDefined(); + expect(view.queryByText("diff --git a/script.sh b/script.sh")).toBeNull(); +}); + test("context meter exposes measured progress without inventing an unknown percentage", () => { const data = { segments: [], totalTokens: 200_000, maxTokens: 1_000_000, totalPercentage: 20 }; const view = render(); From 900ea0cc977b0befc47d938087bf93a1454f81a2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 06:31:18 +0000 Subject: [PATCH 78/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20disable=20G?= =?UTF-8?q?it=20changes=20for=20transcript-only=20workspaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep deleted-checkout transcripts readable without offering a Git action that must fail. Cover disabled navigation, zero diff RPCs, and restoration when the checkout returns. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$1642.49`_ --- packages/mobile/src/screens/ConversationScreen.tsx | 3 ++- packages/mobile/src/screens/session.behavior.tsx | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index f946a19c81e..1c736a05c07 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -107,6 +107,7 @@ export function ConversationScreen(props: { }, [transcript]); const agentId = resolvePersistedAgentId(props.workspace); const modeLocked = props.workspace.parentWorkspaceId != null; + // Transcript access survives checkout removal; Git actions do not. const transcriptOnly = props.workspace.transcriptOnly === true; const options = settings ? resolveSettings( @@ -381,7 +382,7 @@ export function ConversationScreen(props: { label="View changes" icon={GitCompareArrows} onPress={props.onChanges} - disabled={props.workspace.kind === "scratch"} + disabled={props.workspace.kind === "scratch" || transcriptOnly} /> diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index 626bc05c89a..d94b2388632 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -392,6 +392,11 @@ test("transcript-only workspaces keep history and drafts but expose no send or r expect(view.queryByLabelText("Message")).toBeNull(); expect(view.queryByRole("button", { name: "Send message" })).toBeNull(); expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + const changes = view.getByRole("button", { name: "View changes" }); + expect(changes.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(changes); + expect(callCount(view, "getProjectDiffs")).toBe(0); + expect(stackState.routes.at(-1)?.name).toBe("Conversation"); fireEvent.click(oldResume); fireEvent.keyDown(oldInput, { key: "Enter", ctrlKey: true }); expect(callCount(view, "sendMessage")).toBe(0); @@ -400,6 +405,9 @@ test("transcript-only workspaces keep history and drafts but expose no send or r fireEvent.click(await view.findByRole("button", { name: "alpha" })); expect(await view.findByRole("note")).toBeDefined(); await view.updateWorkspace(workspaces[0]); + expect(view.getByRole("button", { name: "View changes" }).getAttribute("aria-disabled")).not.toBe( + "true" + ); expect(await view.findByLabelText("Message")).toHaveProperty("value", "Retained draft"); await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); expect(callCount(view, "sendMessage")).toBe(1); From de171a08735b942f203d5a9ceaa4c216c5882183 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 06:32:01 +0000 Subject: [PATCH 79/84] =?UTF-8?q?=F0=9F=A4=96=20fix(auth):=20retry=20rejec?= =?UTF-8?q?ted=20stored=20tokens=20without=20bearer=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear persisted and in-memory bearer state on a current ticket-mint 401, then try the ordinary credential-free connection before requiring authentication. Keep retries generation-safe and preserve ticket/cookie security boundaries. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$427.30`_ --- src/browser/contexts/API.test.tsx | 170 +++++++++++++++++++++++++----- src/browser/contexts/API.tsx | 24 +++-- 2 files changed, 162 insertions(+), 32 deletions(-) diff --git a/src/browser/contexts/API.test.tsx b/src/browser/contexts/API.test.tsx index 7d7abc951c7..bf9370febf8 100644 --- a/src/browser/contexts/API.test.tsx +++ b/src/browser/contexts/API.test.tsx @@ -250,30 +250,140 @@ describe("API reconnection", () => { expect(clearStoredAuthTokenMock).not.toHaveBeenCalled(); }); + test.each([false, true])( + "a rejected stale token reconnects without bearer credentials (cookie=%s)", + async (cookie) => { + storedAuthToken = "stale-master"; + if (cookie) document.cookie = "mux-session=existing-user-session"; + let mints = 0; + fetchImpl = (input) => { + if (String(input).includes("issueWebSocketTicket")) { + mints++; + return Promise.resolve(new Response("Unauthorized", { status: 401 })); + } + return Promise.resolve(new Response("", { status: 404 })); + }; + const reload = spyOn(window.location, "reload").mockImplementation(() => undefined); + let state: UseAPIResult | undefined; + const statuses: string[] = []; + try { + render( + + { + state = value.apiState; + statuses.push(value.status); + }} + /> + + ); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)); + const clean = MockWebSocket.lastInstance()!; + expect(clean.url).toBe("wss://mux.example.com/orpc/ws"); + expect(clean.protocols).toBeUndefined(); + expect(storedAuthToken).toBeNull(); + await act(async () => { + clean.simulateOpen(); + await Promise.resolve(); + }); + expect(state?.status).toBe("connected"); + expect(statuses).not.toContain("auth_required"); + act(() => state!.retry()); + expect(MockWebSocket.instances).toHaveLength(2); + expect(MockWebSocket.lastInstance()!.protocols).toBeUndefined(); + expect(mints).toBe(1); + expect(reload).not.toHaveBeenCalled(); + } finally { + reload.mockRestore(); + } + } + ); + + test("a superseded mint's late 401 cannot erase newly authenticated credentials", async () => { + storedAuthToken = "stale-master"; + const first = Promise.withResolvers(); + let mints = 0; + fetchImpl = () => (++mints === 1 ? first.promise : Promise.resolve(ticketResponse())); + let state: UseAPIResult | undefined; + render( + + { + state = value.apiState; + }} + /> + + ); + await waitFor(() => expect(mints).toBe(1)); + act(() => state!.authenticate("new-master")); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)); + await act(async () => { + first.resolve(new Response("Unauthorized", { status: 401 })); + await Promise.resolve(); + }); + expect(storedAuthToken).toBe("new-master"); + expect(clearStoredAuthTokenMock).not.toHaveBeenCalled(); + const socket = MockWebSocket.lastInstance()!; + expect(socket.protocols).toEqual([ORPC_WS_PROTOCOL, `${ORPC_WS_TICKET_PREFIX}${testTicket}`]); + await act(async () => { + socket.simulateOpen(); + await Promise.resolve(); + }); + expect(state?.status).toBe("connected"); + }); + test.each([ [401, "auth_required"], [404, "error"], - ] as const)( - "ticket HTTP %s produces %s without a socket or secret fallback", - async (status, expected) => { - storedAuthToken = "master-secret"; - fetchImpl = () => Promise.resolve(new Response("master-secret", { status })); - let state: UseAPIResult | undefined; - render( - - { - state = value.apiState; - }} - /> - - ); - await waitFor(() => expect(state?.status).toBe(expected)); - expect(MockWebSocket.instances).toHaveLength(0); - expect(state?.error).not.toContain("master-secret"); - expect(clearStoredAuthTokenMock).toHaveBeenCalledTimes(status === 401 ? 1 : 0); + ] as const)("ticket HTTP %s produces %s without a secret fallback", async (status, expected) => { + storedAuthToken = "master-secret"; + fetchImpl = () => Promise.resolve(new Response("master-secret", { status })); + let state: UseAPIResult | undefined; + render( + + { + state = value.apiState; + }} + /> + + ); + if (status === 401) { + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)); + expect(MockWebSocket.lastInstance()!.protocols).toBeUndefined(); + act(() => MockWebSocket.lastInstance()!.simulateClose(4401)); } - ); + await waitFor(() => expect(state?.status).toBe(expected)); + expect(MockWebSocket.instances).toHaveLength(status === 401 ? 1 : 0); + expect(state?.error).not.toContain("master-secret"); + if (status === 401) expect(storedAuthToken).toBeNull(); + else expect(clearStoredAuthTokenMock).not.toHaveBeenCalled(); + }); + + test("transient mint errors retry with the same bearer rather than probing anonymously", async () => { + storedAuthToken = "valid-master"; + const authorizations: Array = []; + fetchImpl = (_input, init) => { + authorizations.push(new Headers(init?.headers).get("Authorization")); + return Promise.resolve( + authorizations.length === 1 + ? new Response("Unavailable", { status: 503 }) + : ticketResponse() + ); + }; + render( + + undefined} /> + + ); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)); + expect(authorizations).toEqual(["Bearer valid-master", "Bearer valid-master"]); + expect(MockWebSocket.lastInstance()!.protocols).toEqual([ + ORPC_WS_PROTOCOL, + `${ORPC_WS_TICKET_PREFIX}${testTicket}`, + ]); + expect(clearStoredAuthTokenMock).not.toHaveBeenCalled(); + }); test("superseding token and unmount cancel pending ticket requests and discard late responses", async () => { storedAuthToken = "old-secret"; @@ -385,7 +495,7 @@ describe("API reconnection", () => { expect(clearStoredAuthTokenMock).not.toHaveBeenCalled(); }); - test("HTTP authentication failures after reconnect stop the loop instead of reusing an expired ticket", async () => { + test("HTTP authentication failures after reconnect probe anonymously once before requiring auth", async () => { storedAuthToken = "master-secret"; fetchImpl = () => Promise.resolve(ticketResponse()); const states: ObservedState[] = []; @@ -401,11 +511,23 @@ describe("API reconnection", () => { await Promise.resolve(); }); expect(states.at(-1)?.status).toBe("connected"); - fetchImpl = () => Promise.resolve(new Response("Unauthorized", { status: 401 })); + let mintFailures = 0; + fetchImpl = (input) => { + if (String(input).includes("issueWebSocketTicket")) { + mintFailures++; + return Promise.resolve(new Response("Unauthorized", { status: 401 })); + } + return Promise.resolve(Response.json({ security: [{ bearerAuth: [] }] })); + }; act(() => first.simulateClose(1006)); + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(2)); + const probe = MockWebSocket.lastInstance()!; + expect(probe.protocols).toBeUndefined(); + act(() => probe.simulateClose(1006)); await waitFor(() => expect(states.at(-1)?.status).toBe("auth_required")); - expect(MockWebSocket.instances).toHaveLength(1); - expect(clearStoredAuthTokenMock).toHaveBeenCalledTimes(1); + expect(MockWebSocket.instances).toHaveLength(2); + expect(mintFailures).toBe(1); + expect(storedAuthToken).toBeNull(); }); test("injected clients skip internal auth token setup", async () => { diff --git a/src/browser/contexts/API.tsx b/src/browser/contexts/API.tsx index 4f1c3fe06d9..a2a373d7d01 100644 --- a/src/browser/contexts/API.tsx +++ b/src/browser/contexts/API.tsx @@ -195,6 +195,8 @@ function ManagedAPIProvider(props: Omit) { return getStoredAuthToken(); }); + // Connection callbacks can schedule retries before React commits a token change. + const authTokenRef = useRef(authToken); const cleanupRef = useRef<(() => void) | null>(null); const hasConnectedRef = useRef(false); const reconnectAttemptRef = useRef(0); @@ -284,10 +286,15 @@ function ManagedAPIProvider(props: Omit) { cleanup(); forceReconnectInProgressRef.current = false; if (error instanceof WebSocketTicketError && error.reason === "authentication") { - authRequiredRef.current = true; clearStoredAuthToken(); + authTokenRef.current = null; + setAuthToken(null); + // Auth may have been disabled, or an existing cookie may suffice. Try the + // ordinary credential-free path once; its auth checks still own the modal. hasConnectedRef.current = false; - setState({ status: "auth_required", error: error.message }); + authProbeAttemptedRef.current = false; + reconnectAttemptRef.current = 0; + scheduleReconnectRef.current?.(); } else if (error instanceof WebSocketTicketError && error.reason === "unsupported") { setState({ status: "error", error: error.message }); } else { @@ -543,9 +550,9 @@ function ManagedAPIProvider(props: Omit) { setState({ status: "reconnecting", attempt: attempt + 1 }); reconnectTimeoutRef.current = setTimeout(() => { - connect(authToken); + connect(authTokenRef.current); }, delay); - }, [authToken, connect]); + }, [connect]); // Keep ref in sync with latest scheduleReconnect scheduleReconnectRef.current = scheduleReconnect; @@ -583,7 +590,7 @@ function ManagedAPIProvider(props: Omit) { forceReconnectInProgressRef.current = true; console.warn(`[APIProvider] ${reason}; reconnecting...`); cleanup(); - if (!effectDisposed) connect(authToken); + if (!effectDisposed) connect(authTokenRef.current); return true; }; @@ -688,11 +695,12 @@ function ManagedAPIProvider(props: Omit) { clearInterval(intervalId); outstandingProbeRef.current = null; }; - }, [liveClient, liveCleanup, props.createWebSocket, connect, authToken]); + }, [liveClient, liveCleanup, props.createWebSocket, connect]); const authenticate = useCallback( (token: string) => { authProbeAttemptedRef.current = false; + authTokenRef.current = token; setStoredAuthToken(token); setAuthToken(token); connect(token); @@ -702,8 +710,8 @@ function ManagedAPIProvider(props: Omit) { const retry = useCallback(() => { authProbeAttemptedRef.current = false; - connect(authToken); - }, [connect, authToken]); + connect(authTokenRef.current); + }, [connect]); // Convert internal state to the discriminated union API const value = useMemo((): UseAPIResult => { From 20cdf9765857ab9b0ba362f832c217a1c103e674 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 06:49:48 +0000 Subject: [PATCH 80/84] =?UTF-8?q?=F0=9F=A4=96=20tests(auth):=20inspect=20r?= =?UTF-8?q?equest=20URLs=20without=20implicit=20object=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle Request objects explicitly in ticket-mint fixtures so integrated type-aware lint passes. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$1655.69`_ --- src/browser/contexts/API.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/browser/contexts/API.test.tsx b/src/browser/contexts/API.test.tsx index bf9370febf8..2d1478ae0c0 100644 --- a/src/browser/contexts/API.test.tsx +++ b/src/browser/contexts/API.test.tsx @@ -257,7 +257,9 @@ describe("API reconnection", () => { if (cookie) document.cookie = "mux-session=existing-user-session"; let mints = 0; fetchImpl = (input) => { - if (String(input).includes("issueWebSocketTicket")) { + if ( + (input instanceof Request ? input.url : input.toString()).includes("issueWebSocketTicket") + ) { mints++; return Promise.resolve(new Response("Unauthorized", { status: 401 })); } @@ -513,7 +515,9 @@ describe("API reconnection", () => { expect(states.at(-1)?.status).toBe("connected"); let mintFailures = 0; fetchImpl = (input) => { - if (String(input).includes("issueWebSocketTicket")) { + if ( + (input instanceof Request ? input.url : input.toString()).includes("issueWebSocketTicket") + ) { mintFailures++; return Promise.resolve(new Response("Unauthorized", { status: 401 })); } From 52503c46d9fbed80541b624d09d46ad3213e7efd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 08:53:17 +0000 Subject: [PATCH 81/84] =?UTF-8?q?=F0=9F=A4=96=20refactor(mobile):=20move?= =?UTF-8?q?=20the=20companion=20to=20a=20stateless=20HTTP=20oRPC=20transpo?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every call is its own bearer-authenticated request and every subscription its own streamed response; no socket, ticket or per-connection state survives a network change. Streams heal independently with capped backoff and wake on foreground; a dropped conversation resumes from the server cursor and reconciles the suffix atomically instead of replaying the transcript. Native uses expo/fetch for streamed bodies. The desktop web client keeps WebSockets. --- _Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking: `high` • Cost: `$1676.39`_ --- docs/integrations/mobile-app.md | 6 +- packages/mobile/App.tsx | 23 +- packages/mobile/scripts/preview.test.ts | 27 +- packages/mobile/scripts/preview.ts | 20 +- .../mobile/scripts/preview.websocket.test.ts | 127 ----- packages/mobile/src/api.test.ts | 453 +++++------------- packages/mobile/src/api.ts | 130 ++--- packages/mobile/src/endpoint.ts | 2 +- packages/mobile/src/nativeTestPlatform.ts | 15 + .../mobile/src/screens/ConversationScreen.tsx | 7 +- .../mobile/src/screens/CreateWorkspace.tsx | 56 +-- .../mobile/src/screens/session.behavior.tsx | 52 +- packages/mobile/src/streams.test.ts | 216 +++++++++ packages/mobile/src/streams.ts | 126 +++++ packages/mobile/src/transcript.ts | 24 + packages/mobile/src/transportFetch.native.ts | 8 + packages/mobile/src/transportFetch.ts | 3 + packages/mobile/src/useConversation.test.ts | 104 +++- packages/mobile/src/useConversation.ts | 245 ++++++---- packages/mobile/src/useProjects.test.ts | 77 ++- packages/mobile/src/useProjects.ts | 179 ++++--- .../builtInSkillContent.generated.ts | 6 +- 22 files changed, 1093 insertions(+), 813 deletions(-) delete mode 100644 packages/mobile/scripts/preview.websocket.test.ts create mode 100644 packages/mobile/src/streams.test.ts create mode 100644 packages/mobile/src/streams.ts create mode 100644 packages/mobile/src/transportFetch.native.ts create mode 100644 packages/mobile/src/transportFetch.ts diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index a5b61661709..4a823d4445e 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -15,7 +15,7 @@ During development, run the mobile client and server from the same branch/revisi The token grants access to the server, including its code-execution capabilities. Treat it like a password. Native builds save connection details in device secure storage. The web preview keeps them in memory only; refreshing requires entering them again. Disconnect clears the saved native connection. -Before opening a WebSocket, the companion exchanges the token in an HTTP Authorization header for a short-lived, single-use upgrade ticket. The long-lived token is not included in the WebSocket URL or subprotocols. Older servers without ticket support must be updated; there is no credential-URL fallback. +The companion talks to the server over plain HTTP oRPC: every call is its own request carrying the token in an `Authorization` header, and each live subscription (conversation events, workspace metadata, config/provider/policy changes) is its own streamed response. There is no shared socket or per-connection state, so a cellular handoff or a backgrounded app only interrupts the streams that were open; each one reconnects on its own with capped backoff, and a dropped conversation resumes from the server's cursor rather than replaying the whole transcript. Unary calls and mutations are never retried automatically. The token never appears in a URL. All non-loopback endpoints—including private LAN, ULA, and link-local addresses—require HTTPS. HTTP is accepted only for `localhost`, IPv4 loopback (`127.0.0.0/8`), or IPv6 loopback (`[::1]`) for development, with a plaintext-token warning. A phone's `localhost` refers to the phone, not your development computer: use a trusted HTTPS endpoint to reach that computer from a device. @@ -51,7 +51,7 @@ make mobile-export XUM_MOBILE_ENDPOINT=http://127.0.0.1:3000 make mobile-preview ``` -The preview is development tooling, not a general-purpose public proxy. It intentionally runs under Node: Bun's Node HTTP compatibility can stall forwarded WebSocket frames. +The preview is development tooling, not a general-purpose public proxy. It runs under Node and forwards streamed subscription responses without buffering. ## Native development @@ -108,7 +108,7 @@ For a browser walkthrough, use the production preview and a phone viewport aroun 3. Send a message, observe streamed text/tools/reasoning, and interrupt a running turn. 4. Change agent/model settings, switch workspaces, and verify conversations do not mix. 5. Open Changes and Settings; disconnect and confirm credentials are not retained in browser storage. -6. Drop the connection, retry, and verify authoritative history reloads before sending is enabled. +6. Drop the connection mid-conversation: the transcript stays visible read-only while “Reconnecting…” shows, then resumes in place once the server is reachable again, and sending is re-enabled only after that resync. 7. Capture screenshots and a short recording of the walkthrough, including narrow layouts and any failure/recovery steps. ## Can Xum run inside the native JS engine? diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx index b90feecdba4..63406dcb04f 100644 --- a/packages/mobile/App.tsx +++ b/packages/mobile/App.tsx @@ -1,6 +1,13 @@ -import { createContext, useContext, useRef, useState, useSyncExternalStore } from "react"; +import { + createContext, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; import type { ComponentProps, ReactNode } from "react"; -import { StatusBar, Text, useWindowDimensions, View } from "react-native"; +import { AppState, StatusBar, Text, useWindowDimensions, View } from "react-native"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { DarkTheme, NavigationContainer } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; @@ -18,6 +25,7 @@ import { Button, Header, Loading, Notice } from "./src/components/Controls"; import { KeyboardProvider } from "./src/components/Keyboard"; import { useProjects } from "./src/useProjects"; import { useConnection } from "./src/useConnection"; +import { useStreamsReconnecting, wakeStreams } from "./src/streams"; import { colors, layout, WIDE_LAYOUT_MIN_WIDTH } from "./src/theme"; import { createSessionDrafts } from "./src/sessionDrafts"; import type { ChatSettings } from "./src/settings"; @@ -69,6 +77,14 @@ export default function App() { export function ConnectedApp(props: { connection: Connection; onDisconnect: () => void }) { const session = useConnection(props.connection); const data = useProjects(session.connection.client, session.signal); + useEffect(() => { + // Backgrounded apps lose their streams; retry the moment the user returns rather + // than waiting out a backoff that was scheduled while suspended. + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") wakeStreams(); + }); + return () => subscription.remove(); + }, []); // Full drafts and unsent model choices survive native back/pop and reconnection. const [drafts] = useState(createSessionDrafts); const [selections, setSelections] = useState>({}); @@ -251,11 +267,12 @@ function DraftConversation( function ConversationRoute(props: NativeStackScreenProps) { const { session, data, selections, setSelection } = useSession(); + const streamsReconnecting = useStreamsReconnecting(); const { workspaceId } = props.route.params; const workspace = data.workspaces.find((item) => item.id === workspaceId); return ( - {session.reconnecting && } + {(session.reconnecting || streamsReconnecting) && } {session.error && {session.error}} {workspace ? ( { const preview = await listen( createPreviewServer({ endpoint: `${endpoint}/@me/dev/apps/xum`, origin }) ); - const result = await fetch(`${preview}/__xum/orpc/serverAuth/issueWebSocketTicket`, { + const result = await fetch(`${preview}/__xum/orpc/workspace/list`, { method: "POST", headers: { host, @@ -49,7 +49,7 @@ describe("fixed-target mobile preview", () => { }); const body = await result.json(); expect(result.status).toBe(200); - expect(body.url).toBe("/@me/dev/apps/xum/orpc/serverAuth/issueWebSocketTicket"); + expect(body.url).toBe("/@me/dev/apps/xum/orpc/workspace/list"); expect(body.headers.authorization).toBe("Bearer test-only"); expect(body.headers.origin).toBe(endpoint); expect(body.headers.cookie).toBeUndefined(); @@ -87,3 +87,26 @@ describe("fixed-target mobile preview", () => { ).toBe(404); }); }); + +test("streams a long-lived subscription response through without buffering", async () => { + let push!: (chunk: string) => void; + const endpoint = await listen( + http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + push = (chunk) => res.write(chunk); + push(": open\n\n"); + }) + ); + const preview = await listen(createPreviewServer({ endpoint, origin })); + const response = await fetch(`${preview}/__xum/orpc/workspace/onChat`, { + method: "POST", + headers: { host, origin }, + }); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + expect(decoder.decode((await reader.read()).value)).toContain(": open"); + // A second chunk must arrive while the upstream response is still open. + push("event: message\ndata: {}\n\n"); + expect(decoder.decode((await reader.read()).value)).toContain("event: message"); + await reader.cancel(); +}); diff --git a/packages/mobile/scripts/preview.ts b/packages/mobile/scripts/preview.ts index 299fac75ecf..95735b457f1 100644 --- a/packages/mobile/scripts/preview.ts +++ b/packages/mobile/scripts/preview.ts @@ -16,6 +16,8 @@ export function createPreviewServer(options: PreviewOptions) { const endpoint = normalizeEndpoint(options.endpoint); const target = new URL(endpoint); const origin = new URL(options.origin); + // Subscriptions are long-lived streamed responses; the server's keep-alive comments + // arrive well within this idle bound, so only a dead upstream trips it. const proxy = httpProxy.createProxyServer({ changeOrigin: true, proxyTimeout: 30_000 }); const allowed = (req: http.IncomingMessage) => req.headers.host === origin.host && @@ -24,11 +26,7 @@ export function createPreviewServer(options: PreviewOptions) { function route(req: http.IncomingMessage): string | null { const pathname = (req.url ?? "/").split("?")[0]; - if ( - pathname === "/__xum/orpc" || - pathname === "/__xum/orpc/ws" || - pathname?.startsWith("/__xum/orpc/") - ) { + if (pathname === "/__xum/orpc" || pathname?.startsWith("/__xum/orpc/")) { req.url = `${target.pathname.replace(/\/$/, "")}${req.url!.slice("/__xum".length)}`; // SECURITY: only a same-origin caller can use this fixed upstream. Do not // leak preview cookies/forwarded identity or let the client pick a target. @@ -74,18 +72,6 @@ export function createPreviewServer(options: PreviewOptions) { } res.writeHead(404).end("Not found"); }); - server.on("upgrade", (req, socket, head) => { - if (!allowed(req)) { - socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); - return; - } - const upstream = route(req); - if (!upstream) { - socket.destroy(); - return; - } - proxy.ws(req, socket, head, { target: upstream }, () => socket.destroy()); - }); server.on("close", () => proxy.close()); return server; } diff --git a/packages/mobile/scripts/preview.websocket.test.ts b/packages/mobile/scripts/preview.websocket.test.ts deleted file mode 100644 index 5bd1038b202..00000000000 --- a/packages/mobile/scripts/preview.websocket.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { expect, test } from "bun:test"; -import net from "node:net"; -import type { AddressInfo } from "node:net"; -import { fileURLToPath } from "node:url"; -import { once } from "node:events"; -import { WebSocket } from "ws"; -import { - ORPC_WS_PROTOCOL, - ORPC_WS_TICKET_PREFIX, -} from "../../../src/common/constants/webSocketAuth"; - -// Exercise the same Node entry used by make mobile-web. Bun's node:http proxy -// accepts upgrades but can stall binary oRPC frames, invisible to HTTP-only tests. -test("Node preview forwards binary WebSocket frames with ticket protocols intact and browser identity stripped", async () => { - const ticket = "a".repeat(64); - const protocols = [ORPC_WS_PROTOCOL, ORPC_WS_TICKET_PREFIX + ticket]; - const requests: Request[] = []; - const upstream = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req, server) { - const url = new URL(req.url); - requests.push(req); - if ( - url.pathname !== "/prefix/orpc/ws" || - url.search || - req.headers.get("sec-websocket-protocol")?.split(/,\s*/).join(",") !== protocols.join(",") - ) - return new Response("Unauthorized", { status: 401 }); - return server.upgrade(req) ? undefined : new Response("Upgrade required", { status: 400 }); - }, - websocket: { - message(socket, message) { - socket.send(message); - }, - }, - }); - const reservation = net.createServer(); - await new Promise((resolve) => reservation.listen(0, "127.0.0.1", resolve)); - const port = (reservation.address() as AddressInfo).port; - await new Promise((resolve) => reservation.close(() => resolve())); - const child = Bun.spawn(["node", ".expo/preview.mjs"], { - cwd: fileURLToPath(new URL("../", import.meta.url)), - env: { - ...process.env, - XUM_MOBILE_ENDPOINT: `http://127.0.0.1:${upstream.port}/prefix`, - XUM_MOBILE_PORT: String(port), - XUM_MOBILE_ORIGIN: `http://127.0.0.1:${port}`, - }, - stdout: "pipe", - stderr: "inherit", - }); - let socket: WebSocket | undefined; - let timeout: ReturnType | undefined; - try { - await Promise.race([ - (async () => { - const reader = child.stdout.getReader(); - let output = ""; - while (!output.includes("Xum mobile preview:")) { - const part = await reader.read(); - if (part.done) throw new Error("Preview exited before ready"); - output += new TextDecoder().decode(part.value); - } - reader.releaseLock(); - })(), - new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error("Preview startup timed out")), 3000); - }), - ]); - clearTimeout(timeout); - const bytes = new Uint8Array([0, 128, 255, 10]); - socket = new WebSocket(`ws://127.0.0.1:${port}/__xum/orpc/ws`, protocols, { - headers: { - origin: `http://127.0.0.1:${port}`, - cookie: "private-preview-cookie", - forwarded: "host=attacker.test", - "x-forwarded-host": "attacker.test", - }, - }); - socket.binaryType = "arraybuffer"; - const reply = await new Promise((resolve, reject) => { - const ws = socket!; - timeout = setTimeout(() => reject(new Error("Proxy stalled WebSocket frames")), 3000); - ws.onopen = () => ws.send(bytes); - ws.onmessage = (event) => resolve(event.data as ArrayBuffer); - ws.onerror = () => reject(new Error("Proxy WebSocket failed")); - }); - expect(new Uint8Array(reply)).toEqual(bytes); - expect(socket.protocol).toBe(protocols[0]); - expect(requests).toHaveLength(1); - expect(requests[0].headers.get("cookie")).toBeNull(); - expect(requests[0].headers.get("authorization")).toBeNull(); - expect(requests[0].headers.get("forwarded")).toBeNull(); - expect(requests[0].headers.get("x-forwarded-host")).toBeNull(); - expect(requests[0].headers.get("origin")).toBe(`http://127.0.0.1:${upstream.port}`); - const rejected = net.createConnection({ host: "127.0.0.1", port }); - try { - await once(rejected, "connect"); - rejected.write( - [ - "GET /__xum/orpc/ws HTTP/1.1", - `Host: 127.0.0.1:${port}`, - "Connection: Upgrade", - "Upgrade: websocket", - "Origin: https://attacker.test", - "Sec-WebSocket-Version: 13", - "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", - `Sec-WebSocket-Protocol: ${protocols.join(", ")}`, - "", - "", - ].join("\r\n") - ); - const [response] = await once(rejected, "data"); - expect(String(response)).toContain("403 Forbidden"); - expect(requests).toHaveLength(1); - } finally { - rejected.destroy(); - } - } finally { - clearTimeout(timeout); - socket?.close(); - child.kill(); - await child.exited; - upstream.stop(true); - } -}, 10_000); diff --git a/packages/mobile/src/api.test.ts b/packages/mobile/src/api.test.ts index 83f2ba7e090..6c2d343ba65 100644 --- a/packages/mobile/src/api.test.ts +++ b/packages/mobile/src/api.test.ts @@ -1,21 +1,12 @@ import { describe, expect, spyOn, test } from "bun:test"; import { ORPCError, os } from "@orpc/server"; -import { RPCHandler } from "@orpc/server/websocket"; -import { RPCHandler as HTTPHandler } from "@orpc/server/node"; +import { RPCHandler } from "@orpc/server/node"; import { createServer } from "node:http"; -import type { IncomingMessage } from "node:http"; -import { randomBytes } from "node:crypto"; import { z } from "zod"; import { once } from "node:events"; import assert from "node:assert/strict"; -import { WebSocketServer } from "ws"; import { connect } from "./api"; import { connect as connectPreview } from "./connection.web"; -import { - ORPC_WS_PROTOCOL, - ORPC_WS_TICKET_PREFIX, - ORPC_WS_TICKET_TTL_MS, -} from "../../../src/common/constants/webSocketAuth"; import type { WorkspaceChatMessage } from "./transcript"; async function expectFailure(promise: Promise, message?: string): Promise { @@ -24,37 +15,21 @@ async function expectFailure(promise: Promise, message?: string): Promi if (message) expect(String(error)).toContain(message); } -async function serverFixture( - stallProbe = false, - selectProtocol: (protocols: Set) => string | false = () => ORPC_WS_PROTOCOL -) { +async function serverFixture(stallProbe = false) { const token = "private token/+?"; let calls = 0; - let upgrades = 0; let mutations = 0; - let onOpen: () => void = () => undefined; - let onClose: () => void = () => undefined; - const opened = new Promise((resolve) => { - onOpen = resolve; - }); - const closed = new Promise((resolve) => { - onClose = resolve; + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + const subscriptions: Array<{ aborted: () => boolean }> = []; + let onSubscribe: () => void = () => undefined; + const subscribed = new Promise((resolve) => { + onSubscribe = resolve; }); const procedure = os.$context<{ authenticated: boolean }>().use(({ context, next }) => { if (!context.authenticated) throw new ORPCError("UNAUTHORIZED"); return next(); }); - const tickets = new Set(); - const mintRequests: Array<{ url: string | undefined; authorization: string | undefined }> = []; - const handshakes: Array<{ url: string | undefined; protocols: string | undefined }> = []; const router = { - serverAuth: { - issueWebSocketTicket: procedure.handler(() => { - const ticket = randomBytes(32).toString("hex"); - tickets.add(ticket); - return { ticket, expiresAtMs: Date.now() + ORPC_WS_TICKET_TTL_MS }; - }), - }, workspace: { list: procedure.handler(async ({ signal }) => { calls++; @@ -71,7 +46,9 @@ async function serverFixture( }), onChat: procedure .input(z.object({ workspaceId: z.string(), mode: z.object({ type: z.literal("full") }) })) - .handler(async function* (): AsyncGenerator { + .handler(async function* ({ input, signal }): AsyncGenerator { + subscriptions.push({ aborted: () => signal?.aborted === true }); + onSubscribe(); yield Promise.resolve({ type: "message", id: "one", @@ -79,14 +56,17 @@ async function serverFixture( parts: [{ type: "text", text: "hello" }], }); yield { type: "caught-up", replay: "full" }; + if (input.workspaceId === "open-ended") + await new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(), { once: true }); + }); }), }, }; const handler = new RPCHandler(router); - const httpHandler = new HTTPHandler(router); const httpServer = createServer((request, response) => { - mintRequests.push({ url: request.url, authorization: request.headers.authorization }); - httpHandler + requests.push({ url: request.url, authorization: request.headers.authorization }); + handler .handle(request, response, { prefix: "/proxy/orpc", context: { authenticated: request.headers.authorization === `Bearer ${token}` }, @@ -102,30 +82,6 @@ async function serverFixture( response.end(); }); }); - const server = new WebSocketServer({ - server: httpServer, - path: "/proxy/orpc/ws", - handleProtocols: selectProtocol, - verifyClient: ({ req }: { req: IncomingMessage }) => { - const protocols = req.headers["sec-websocket-protocol"]?.split(/,\s*/); - const ticket = protocols - ?.find((value) => value.startsWith(ORPC_WS_TICKET_PREFIX)) - ?.slice(ORPC_WS_TICKET_PREFIX.length); - handshakes.push({ url: req.url, protocols: req.headers["sec-websocket-protocol"] }); - return ( - req.url === "/proxy/orpc/ws" && - protocols?.includes(ORPC_WS_PROTOCOL) === true && - ticket !== undefined && - tickets.delete(ticket) - ); - }, - }); - server.on("connection", (socket) => { - upgrades++; - handler.upgrade(socket, { context: { authenticated: true } }); - socket.once("close", onClose); - onOpen(); - }); httpServer.listen(0, "127.0.0.1"); await once(httpServer, "listening"); const address = httpServer.address(); @@ -133,21 +89,14 @@ async function serverFixture( return { endpoint: `http://127.0.0.1:${address.port}/proxy`, token, - mintRequests, - handshakes, - opened, - closed, + requests, + subscriptions, + subscribed, calls: () => calls, - upgrades: () => upgrades, mutations: () => mutations, [Symbol.asyncDispose]: async () => { - for (const socket of server.clients) socket.terminate(); - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); + httpServer.closeAllConnections(); await new Promise((resolve, reject) => { - // Bun can stop listening here after terminating an upgraded connection. - httpServer.closeAllConnections(); if (!httpServer.listening) resolve(); else httpServer.close((error) => (error ? reject(error) : resolve())); }); @@ -155,7 +104,7 @@ async function serverFixture( }; } -describe("mobile WebSocket connection", () => { +describe("mobile HTTP connection", () => { test("runtime signals check cancellation and preserve a propagated abort reason", () => { const controller = new AbortController(); expect(() => controller.signal.throwIfAborted()).not.toThrow(); @@ -173,156 +122,118 @@ describe("mobile WebSocket connection", () => { expect(thrown).toBe(combined.reason); }); - test("authenticates unary probe and streams actual oRPC events through a proxy prefix", async () => { + test("authenticates the probe and streams oRPC events over HTTP through a proxy prefix", async () => { await using server = await serverFixture(); const connection = await connect(`${server.endpoint}/`, server.token); - try { - expect(connection.endpoint).toBe(server.endpoint); - expect(server.calls()).toBe(1); - expect(server.mintRequests).toEqual([ - { - url: "/proxy/orpc/serverAuth/issueWebSocketTicket", - authorization: `Bearer ${server.token}`, - }, - ]); - expect(server.handshakes[0].url).toBe("/proxy/orpc/ws"); - expect(server.handshakes[0].protocols).not.toContain(server.token); + expect(connection.endpoint).toBe(server.endpoint); + expect(server.calls()).toBe(1); + expect(server.requests).toEqual([ + { url: "/proxy/orpc/workspace/list", authorization: `Bearer ${server.token}` }, + ]); - const events: WorkspaceChatMessage[] = []; - const subscription = await connection.client.workspace.onChat({ - workspaceId: "w", - mode: { type: "full" }, - }); - for await (const event of subscription) events.push(event); - expect(events.map((event) => event.type)).toEqual(["message", "caught-up"]); - expect(server.upgrades()).toBe(1); - } finally { - connection.close(); - connection.close(); + const events: WorkspaceChatMessage[] = []; + const subscription = await connection.client.workspace.onChat({ + workspaceId: "w", + mode: { type: "full" }, + }); + for await (const event of subscription) events.push(event); + expect(events.map((event) => event.type)).toEqual(["message", "caught-up"]); + // The bearer travels only in the Authorization header, never in a URL. + expect(server.requests.map((request) => request.url)).toEqual([ + "/proxy/orpc/workspace/list", + "/proxy/orpc/workspace/onChat", + ]); + for (const request of server.requests) { + expect(request.url).not.toContain(encodeURIComponent(server.token)); + expect(request.authorization).toBe(`Bearer ${server.token}`); } - await server.closed; - await expectFailure(connection.client.workspace.list()); - expect(server.upgrades()).toBe(1); + + connection.close(); + connection.close(); + await expectFailure(connection.client.workspace.list(), "Connection closed."); expect(server.calls()).toBe(1); }); - test("rejects a ticket-echo protocol even when the server answers the auth probe", async () => { - await using server = await serverFixture( - false, - (protocols) => - [...protocols].find((protocol) => protocol.startsWith(ORPC_WS_TICKET_PREFIX)) ?? false + test("aborting one subscription ends only that stream; unary calls stay usable", async () => { + await using server = await serverFixture(); + const connection = await connect(server.endpoint, server.token); + const controller = new AbortController(); + const subscription = await connection.client.workspace.onChat( + { workspaceId: "open-ended", mode: { type: "full" } }, + { signal: controller.signal } ); - const OriginalWebSocket = globalThis.WebSocket; - // Bun's ws fixture always selects the first offer on the wire, even when - // handleProtocols selects another. Offer the same protocols ticket-first to - // exercise a real ticket-echo handshake and probe rather than mock its result. - class TicketFirstSocket extends OriginalWebSocket { - constructor(url: string, protocols?: string | string[]) { - assert(Array.isArray(protocols)); - super(url, [...protocols].reverse()); - } - } - Object.assign(globalThis, { WebSocket: TicketFirstSocket }); - try { - const result = await connect(server.endpoint, server.token).catch((cause: unknown) => cause); - expect(result).toBeInstanceOf(Error); - expect(server.calls()).toBe(1); - expect(server.upgrades()).toBe(1); - const ticket = server.handshakes[0].protocols - ?.split(/,\s*/) - .find((protocol) => protocol.startsWith(ORPC_WS_TICKET_PREFIX)) - ?.slice(ORPC_WS_TICKET_PREFIX.length); - expect(ticket).toBeDefined(); - expect(String(result)).not.toContain(ticket!); - expect(String(result)).not.toContain(server.token); - expect(String(result)).not.toContain(server.endpoint); - await server.closed; - } finally { - Object.assign(globalThis, { WebSocket: OriginalWebSocket }); - } + await server.subscribed; + const events: WorkspaceChatMessage[] = []; + const consumed = (async () => { + for await (const event of subscription) events.push(event); + })().catch((cause: unknown) => cause); + while (events.length < 2) await new Promise((resolve) => setTimeout(resolve, 5)); + expect(server.subscriptions[0].aborted()).toBe(false); + controller.abort(); + // The client releases the stream immediately. (Bun's fetch keeps the pooled + // socket open after abort, so the server-side abort is not observable here; + // browsers and expo/fetch close the connection, which Node reports as close.) + expect(await consumed).toBeInstanceOf(Error); + expect(await connection.client.workspace.list()).toEqual([]); + connection.close(); }); test("does not retry a failed mutation or dispatch mutations after close", async () => { await using server = await serverFixture(); const connection = await connect(server.endpoint, server.token); - try { - await expectFailure(connection.client.workspace.interruptStream({ workspaceId: "w" })); - expect(server.mutations()).toBe(1); - } finally { - connection.close(); - } - await server.closed; + await expectFailure(connection.client.workspace.interruptStream({ workspaceId: "w" })); + expect(server.mutations()).toBe(1); + connection.close(); await expectFailure( connection.client.workspace.interruptStream({ workspaceId: "w" }), "Connection closed." ); expect(server.mutations()).toBe(1); - expect(server.upgrades()).toBe(1); }); - test("rejects bad auth without exposing token or URL or opening a socket", async () => { + test("rejects bad auth without exposing the token or URL", async () => { await using server = await serverFixture(); const secret = "wrong-secret"; const error = await connect(server.endpoint, secret).catch((error: unknown) => error); expect(error).toBeInstanceOf(Error); + expect(String(error)).toContain("rejected this token"); expect(String(error)).not.toContain(secret); expect(String(error)).not.toContain(server.endpoint); - expect(server.upgrades()).toBe(0); expect(server.calls()).toBe(0); }); - test("cancellation closes the socket before the WebSocket handshake completes", async () => { - let onRequest: (request: Request) => void = () => undefined; - const requested = new Promise((resolve) => { - onRequest = resolve; - }); - const server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch(request) { - if (new URL(request.url).pathname === "/orpc/serverAuth/issueWebSocketTicket") { - return Response.json({ - json: { ticket: "a".repeat(64), expiresAtMs: Date.now() + ORPC_WS_TICKET_TTL_MS }, - }); - } - onRequest(request); - return new Promise((resolve) => { - request.signal.addEventListener( - "abort", - () => resolve(new Response(null, { status: 503 })), - { once: true } - ); - }); - }, - }); - try { - const controller = new AbortController(); - const pending = connect(`http://127.0.0.1:${server.port}`, "secret", { - signal: controller.signal, - }); - const request = await requested; - const disconnected = new Promise((resolve) => { - request.signal.addEventListener("abort", () => resolve(), { once: true }); - }); - controller.abort(); - await expectFailure(pending, "Connection cancelled."); - await disconnected; - } finally { - await server.stop(true); + test.each([404, 500])( + "HTTP %s from the probe is a generic connection failure", + async (status) => { + await using server = await serverFixture(); + const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(server.token, { status }) + ); + try { + const failure = await connect(server.endpoint, server.token).catch( + (cause: unknown) => cause + ); + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).not.toContain(server.token); + expect(String(failure)).not.toContain(server.endpoint); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + fetchMock.mockRestore(); + } } - }); + ); - test("cancellation closes a pending authenticated probe", async () => { + test("cancellation aborts a pending authenticated probe", async () => { await using server = await serverFixture(true); const controller = new AbortController(); const pending = connect(server.endpoint, server.token, { signal: controller.signal }); - await server.opened; + while (server.calls() === 0) await new Promise((resolve) => setTimeout(resolve, 5)); controller.abort("secret cancellation reason"); await expectFailure(pending, "Connection cancelled."); - await server.closed; + expect(server.calls()).toBe(1); }); - test("already aborted signal never opens a socket; lifetime abort closes a connected one", async () => { + test("an already aborted signal never sends a request", async () => { await using server = await serverFixture(); const cancelled = new AbortController(); cancelled.abort(); @@ -330,166 +241,40 @@ describe("mobile WebSocket connection", () => { connect(server.endpoint, server.token, { signal: cancelled.signal }), "cancelled" ); - expect(server.upgrades()).toBe(0); - const lifetime = new AbortController(); - const connection = await connect(server.endpoint, server.token, { signal: lifetime.signal }); - lifetime.abort(); - await server.closed; - connection.close(); - expect(server.upgrades()).toBe(1); + expect(server.requests).toHaveLength(0); }); - test("explicit reconnect mints a fresh ticket without replaying a failed mutation", async () => { - await using server = await serverFixture(); - const first = await connect(server.endpoint, server.token); - await expectFailure(first.client.workspace.interruptStream({ workspaceId: "w" })); - first.close(); - const second = await first.reconnect(); - try { - expect(server.mintRequests).toHaveLength(2); - expect(server.handshakes).toHaveLength(2); - expect(server.handshakes[0].protocols).not.toBe(server.handshakes[1].protocols); - expect(server.mutations()).toBe(1); - } finally { - second.close(); - } - }); - - test.each(["cancel", "timeout"] as const)( - "%s bounds ticket acquisition even when fetch ignores abort", - async (action) => { - await using server = await serverFixture(); - let finishFetch!: (response: Response) => void; - let requested!: () => void; - const started = new Promise((resolve) => { - requested = resolve; - }); - const response = new Promise((resolve) => { - finishFetch = resolve; - }); - let requestSignal: AbortSignal | undefined; - const originalTimeout = globalThis.setTimeout; - let expire!: () => void; - const timer = spyOn(globalThis, "setTimeout").mockImplementation( - Object.assign((...args: Parameters) => { - const [callback, delay, ...callbackArgs] = args; - if (delay === 10_000) expire = () => callback(...callbackArgs); - return originalTimeout(...args); - }, originalTimeout) - ); - const fetchMock = spyOn(globalThis, "fetch").mockImplementation( - Object.assign((...args: Parameters) => { - requestSignal = args[1]?.signal ?? undefined; - requested(); - return response; - }, globalThis.fetch) - ); - try { - const controller = new AbortController(); - const pending = connect(server.endpoint, server.token, { signal: controller.signal }); - await started; - if (action === "cancel") controller.abort("private cancellation reason"); - else expire(); - await expectFailure( - pending, - action === "cancel" ? "Connection cancelled." : "Connection timed out." - ); - expect(requestSignal?.aborted).toBe(true); - finishFetch( - Response.json({ - json: { ticket: "a".repeat(64), expiresAtMs: Date.now() + ORPC_WS_TICKET_TTL_MS }, - }) - ); - await response; - await Promise.resolve(); - expect(server.handshakes).toHaveLength(0); - } finally { - fetchMock.mockRestore(); - timer.mockRestore(); - } - } - ); - - test("cancellation closes a late native handshake that could not close while connecting", async () => { - const OriginalWebSocket = globalThis.WebSocket; - let opened!: (socket: PendingSocket) => void; - const constructed = new Promise((resolve) => { - opened = resolve; - }); - class PendingSocket extends EventTarget { - readyState = 0; - binaryType = "blob"; - closes = 0; - constructor() { - super(); - opened(this); - } - close() { - this.closes++; - if (this.readyState === 0) throw new Error("Cannot close pending native handshake"); - this.readyState = 3; - this.dispatchEvent(new Event("close")); - } - send() { - throw new Error("Cancelled socket must not dispatch RPCs"); - } - } - Object.assign(globalThis, { WebSocket: PendingSocket }); - const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue( - Response.json({ - json: { ticket: "a".repeat(64), expiresAtMs: Date.now() + ORPC_WS_TICKET_TTL_MS }, - }) + test("a stalled authenticated probe times out", async () => { + await using server = await serverFixture(true); + const originalTimeout = globalThis.setTimeout; + const timer = spyOn(globalThis, "setTimeout").mockImplementation( + Object.assign((...args: Parameters) => { + const [callback, delay, ...callbackArgs] = args; + // Fire the connect deadline immediately; leave every other timer alone. + return originalTimeout(callback, delay === 10_000 ? 0 : delay, ...callbackArgs); + }, originalTimeout) ); try { - const controller = new AbortController(); - const pending = connect("http://localhost", "private token/+?", { - signal: controller.signal, - }); - const lateSocket = await constructed; - controller.abort(); - await expectFailure(pending, "Connection cancelled."); - expect(lateSocket.closes).toBe(1); - lateSocket.readyState = 1; - lateSocket.dispatchEvent(new Event("open")); - expect(lateSocket.closes).toBe(2); - expect(lateSocket.readyState).toBe(3); + await expectFailure(connect(server.endpoint, server.token), "Connection timed out."); } finally { - Object.assign(globalThis, { WebSocket: OriginalWebSocket }); - fetchMock.mockRestore(); + timer.mockRestore(); } }); - test.each([401, 404, 500])( - "ticket HTTP %s fails closed without an insecure upgrade or credential-bearing error", - async (status) => { - await using server = await serverFixture(); - const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue( - new Response(server.token, { status }) - ); - try { - const failure = await connect(server.endpoint, server.token).catch( - (cause: unknown) => cause - ); - expect(failure).toBeInstanceOf(Error); - expect(String(failure)).not.toContain(server.token); - expect(String(failure)).not.toContain(server.endpoint); - expect(server.handshakes).toHaveLength(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - } finally { - fetchMock.mockRestore(); - } - } - ); - - test("a stalled authenticated RPC probe times out and closes its socket", async () => { - await using server = await serverFixture(true); - await expectFailure(connect(server.endpoint, server.token), "Connection timed out."); - await server.closed; - expect(server.upgrades()).toBe(1); - }, 15_000); + test("explicit reconnect re-probes with the same bearer without replaying a failed mutation", async () => { + await using server = await serverFixture(); + const first = await connect(server.endpoint, server.token); + await expectFailure(first.client.workspace.interruptStream({ workspaceId: "w" })); + first.close(); + const second = await first.reconnect(); + expect(server.calls()).toBe(2); + expect(server.mutations()).toBe(1); + expect(await second.client.workspace.list()).toEqual([]); + second.close(); + }); }); -test("non-loopback HTTP is rejected before ticket or preview fetch even with saved credentials", async () => { +test("non-loopback HTTP is rejected before any request even with saved credentials", async () => { const fetchMock = spyOn(globalThis, "fetch").mockRejectedValue( new Error("Network must not be reached") ); diff --git a/packages/mobile/src/api.ts b/packages/mobile/src/api.ts index 4dbfcbe1249..7edcdf2f7b4 100644 --- a/packages/mobile/src/api.ts +++ b/packages/mobile/src/api.ts @@ -1,17 +1,10 @@ -import { createORPCClient } from "@orpc/client"; +import { createORPCClient, ORPCError } from "@orpc/client"; import type { Client, ClientContext } from "@orpc/client"; import type { AnySchema, InferSchemaInput, InferSchemaOutput } from "@orpc/contract"; -import { RPCLink } from "@orpc/client/websocket"; +import { RPCLink } from "@orpc/client/fetch"; import type * as schemas from "../../../src/common/orpc/schemas/api"; -import { - ORPC_WS_PROTOCOL, - ORPC_WS_TICKET_PREFIX, -} from "../../../src/common/constants/webSocketAuth"; -import { - requestWebSocketTicket, - WebSocketTicketError, -} from "../../../src/common/orpc/webSocketTicket"; import { normalizeEndpoint } from "./endpoint"; +import { transportFetch } from "./transportFetch"; // Infer the wire contract without importing the Node router's implementation // graph into a native TypeScript program (Expo and Node declare different globals). @@ -33,10 +26,18 @@ export interface MobileConnection { const CONNECT_TIMEOUT_MS = 10_000; +/** A rejected bearer is terminal for the session; every other failure may be retried. */ +export function isAuthenticationError(cause: unknown): boolean { + return ( + cause instanceof ORPCError && (cause.code === "UNAUTHORIZED" || cause.code === "FORBIDDEN") + ); +} + /** - * One owned socket for both authenticated unary RPC and subscriptions. Reconnect - * is explicit: never retry a mutation, and reset chat before a new full replay. - * The signal owns the connection lifetime, including the pending handshake. + * Stateless HTTP transport: every call is its own request, and every subscription is + * its own streamed response. Nothing survives a network change except the bearer, so + * cellular handoffs cost only the requests that were in flight. Mutations are never + * retried; subscriptions heal themselves (see streams.ts). */ export async function connect( endpoint: string, @@ -46,86 +47,53 @@ export async function connect( const normalized = normalizeEndpoint(endpoint); if (!token.trim()) throw new Error("Enter a server token."); if (options.signal?.aborted) throw new Error("Connection cancelled."); + const base = new URL(normalized); + let closed = false; + const client = createORPCClient( + new RPCLink({ + origin: base.origin, + url: `/${[...base.pathname.split("/").filter(Boolean), "orpc"].join("/")}`, + // The bearer travels only in this header, over HTTPS (or same-device loopback). + headers: { Authorization: `Bearer ${token}` }, + fetch: (url, init) => + transportFetch(url, { ...init, credentials: "omit", redirect: "error", cache: "no-store" }), + interceptors: [ + (options) => { + // Disconnect must not let a late React effect dispatch a mutation. + if (closed) throw new Error("Connection closed."); + return options.next(); + }, + ], + }) + ); + // One deadline covers the authenticated probe; the signal owns cancellation. const probe = new AbortController(); - let socket: WebSocket | undefined; - let closed = false; let timedOut = false; - const closeSocket = () => { - if (!socket) return; - const ownedSocket = socket; - try { - if (ownedSocket.readyState < 2) ownedSocket.close(); - } catch { - // Some native implementations cannot close a pending handshake. Do not - // let a late open outlive cancellation of the connection that owns it. - ownedSocket.addEventListener("open", () => ownedSocket.close(), { once: true }); - } - }; - const close = () => { - if (closed) return; - closed = true; - options.signal?.removeEventListener("abort", close); - socket?.removeEventListener("close", close); - probe.abort(); - closeSocket(); - }; - options.signal?.addEventListener("abort", close, { once: true }); - // One deadline covers both the HTTP mint and the authenticated socket probe. + const cancel = () => probe.abort(); + options.signal?.addEventListener("abort", cancel, { once: true }); const timeout = setTimeout(() => { timedOut = true; - close(); + probe.abort(); }, CONNECT_TIMEOUT_MS); - try { - const { ticket } = await requestWebSocketTicket(normalized, token, probe.signal); - probe.signal.throwIfAborted(); - const url = new URL(`${normalized}/orpc/ws`); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - // Never put the reusable bearer in URLs or protocols; every reconnect mints - // a fresh, short-lived single-use ticket through authenticated HTTP instead. - socket = new WebSocket(url.toString(), [ORPC_WS_PROTOCOL, ORPC_WS_TICKET_PREFIX + ticket]); - socket.binaryType = "arraybuffer"; - socket.addEventListener("close", close); - if (closed) { - closeSocket(); - throw new Error("Connection closed."); - } - const client = createORPCClient( - new RPCLink({ - connect: () => { - if (closed || !socket) throw new Error("Connection closed."); - return socket; - }, - reconnect: { enabled: false }, - // The adapter retains its peer after close; reject before it can queue a - // call that will never receive a response (or replay a mutation). - interceptors: [ - (options) => { - if (closed) throw new Error("Connection closed."); - return options.next(); - }, - ], - }) - ); - // An open handshake alone does not prove RPC authentication succeeded. + // Reachability alone proves nothing; an authenticated RPC must succeed. await client.workspace.list(undefined, { signal: probe.signal }); - if (closed) throw new Error("Connection closed."); - // A ticket is an upgrade credential, never the negotiated application protocol. - if (socket.protocol !== ORPC_WS_PROTOCOL) throw new Error("Connection protocol rejected."); - return { - client, - close, - endpoint: normalized, - reconnect: (options) => connect(normalized, token, options), - }; - } catch (error) { - close(); + } catch (cause) { if (options.signal?.aborted) throw new Error("Connection cancelled."); if (timedOut) throw new Error("Connection timed out."); - if (error instanceof WebSocketTicketError) throw error; + if (isAuthenticationError(cause)) throw new Error("The server rejected this token."); throw new Error("Unable to connect. Check the server address and token."); } finally { clearTimeout(timeout); + options.signal?.removeEventListener("abort", cancel); } + return { + client, + endpoint: normalized, + close: () => { + closed = true; + }, + reconnect: (options) => connect(normalized, token, options), + }; } diff --git a/packages/mobile/src/endpoint.ts b/packages/mobile/src/endpoint.ts index 24685c8cbe1..ae99e474d54 100644 --- a/packages/mobile/src/endpoint.ts +++ b/packages/mobile/src/endpoint.ts @@ -1,6 +1,6 @@ function isLoopbackHost(hostname: string): boolean { if (hostname === "localhost" || hostname === "[::1]") return true; - // Ticket minting still sends the master bearer: private networks are not a + // Every request carries the master bearer: private networks are not a // confidentiality boundary. Classify URL-normalized literals without DNS lookups. const octets = hostname.split(".").map(Number); if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { diff --git a/packages/mobile/src/nativeTestPlatform.ts b/packages/mobile/src/nativeTestPlatform.ts index b89a1f0e05a..bd224a1004a 100644 --- a/packages/mobile/src/nativeTestPlatform.ts +++ b/packages/mobile/src/nativeTestPlatform.ts @@ -9,9 +9,24 @@ if ("throwIfAborted" in NativeAbortSignal.prototype) { throw new Error("Native transport tests require React Native's legacy AbortSignal."); } +// Bun's fetch ignores a foreign AbortSignal, but the platform fetch the app ships with +// (expo/fetch) cancels its native request from React Native's signal. Bridge the +// legacy signal into Bun's so cancellation reaches the network here as well. +const RuntimeAbortController = globalThis.AbortController; +const runtimeFetch = globalThis.fetch; +const bridgedFetch = (input: string | URL | Request, init?: RequestInit): Promise => { + if (!init?.signal) return runtimeFetch(input, init); + const controller = new RuntimeAbortController(); + const forward = () => controller.abort(); + if (init.signal.aborted) forward(); + else init.signal.addEventListener("abort", forward, { once: true }); + return runtimeFetch(input, { ...init, signal: controller.signal }); +}; + // Match RN's setUpXHR plus Expo's winter patch, rather than Bun's newer AbortSignal. Object.assign(globalThis, { AbortController: NativeAbortController, AbortSignal: NativeAbortSignal, + fetch: bridgedFetch, }); installAbortSignalPatch(globalThis.AbortSignal); diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx index 1c736a05c07..3a2f4d7067f 100644 --- a/packages/mobile/src/screens/ConversationScreen.tsx +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -102,7 +102,9 @@ export function ConversationScreen(props: { // Answer RPCs can outlive stream updates from another client. Consult the latest // committed transcript before starting recovery, not the pre-answer render. useEffect(() => { - if (transcript.streaming) setStartedResumeMessageId(null); + // A resync after a dropped stream is authoritative too: if the started turn is not + // in the replay, manual recovery must be offered again. + if (transcript.streaming || !transcript.caughtUp) setStartedResumeMessageId(null); latestTranscript.current = transcript; }, [transcript]); const agentId = resolvePersistedAgentId(props.workspace); @@ -260,8 +262,11 @@ export function ConversationScreen(props: { async function resumeAnsweredQuestion(messageId: string, signal: AbortSignal) { const current = latestTranscript.current; const latest = current.messages.at(-1); + // An unsynced transcript (stream being re-established) is not evidence; the + // replay will re-offer manual recovery if the turn is still waiting. if ( signal.aborted || + !current.caughtUp || current.streaming || latest?.id !== messageId || latest?.metadata?.userStopped || diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx index 8a293c1aef9..eebab683498 100644 --- a/packages/mobile/src/screens/CreateWorkspace.tsx +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -8,6 +8,7 @@ import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/wor import { Button, Field, Loading, Notice, Sheet } from "../components/Controls"; import { colors, layout, radii, spacing, typography } from "../theme"; import { linkedAbortController } from "../useConnection"; +import { watch } from "../streams"; import { resolveWorkspaceCreationScope } from "../../../../src/common/utils/subProjects"; import type { PolicyGetResponse } from "../../../../src/common/orpc/types"; import { RUNTIME_MODE } from "../../../../src/common/types/runtime"; @@ -89,40 +90,31 @@ export function CreateWorkspace(props: { publish(null, POLICY_UNAVAILABLE_MESSAGE); } publish(null); - async function watch() { - const events = await props.client.policy.onChanged(undefined, { signal: lifetime.signal }); - if (lifetime.signal.aborted) { - await events.return?.(); - return; - } - function refresh() { - if (lifetime.signal.aborted) return; - request?.abort(); - const next = linkedAbortController(lifetime.signal); - request = next; - publish(null); - props.client.policy - .get(undefined, { signal: next.signal }) - .then( - (response) => { - if (!next.signal.aborted) publish(response); - }, - () => { - if (!next.signal.aborted) unavailable(); - } - ) - .finally(() => next.abort()); - } + function refresh() { // Listen first, and keep consuming invalidations while a read is pending. - refresh(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Notifications have no payload. - for await (const _ of events) { - if (lifetime.signal.aborted) return; - refresh(); - } - unavailable(); + request?.abort(); + const next = linkedAbortController(lifetime.signal); + request = next; + publish(null); + props.client.policy + .get(undefined, { signal: next.signal }) + .then( + (response) => { + if (!next.signal.aborted) publish(response); + }, + () => { + if (!next.signal.aborted) unavailable(); + } + ) + .finally(() => next.abort()); } - if (!props.signal.aborted) watch().catch(unavailable); + watch({ + signal: lifetime.signal, + open: (attempt) => props.client.policy.onChanged(undefined, { signal: attempt.signal }), + onOpen: refresh, + onEvent: refresh, + onLost: unavailable, + }).catch(unavailable); return () => { lifetime.abort(); request?.abort(); diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index d94b2388632..827630537ae 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -2,9 +2,10 @@ import { navigatorUpdates } from "./navigatorTestProfiler"; import { secureStore, stackState } from "./sessionTestPlatform"; import { afterEach, describe, expect, test } from "bun:test"; import { act, cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; -import { createORPCClient } from "@orpc/client"; +import { createORPCClient, ORPCError } from "@orpc/client"; import { ConnectedApp } from "../../App"; import type { Connection } from "./ConnectScreen"; +import { wakeStreams } from "../streams"; import type { MobileClient } from "../api"; import type { SettingsData } from "../settings"; import { getWebComposerKeyAction } from "../composerKeyboard"; @@ -56,6 +57,7 @@ function fixture( workspaceId: string; signal: AbortSignal; events: ReadableStreamDefaultController; + fail: (cause: unknown) => void; end: () => void; }> = []; const calls: Array<{ path: string; input: unknown; signal?: AbortSignal }> = []; @@ -85,7 +87,11 @@ function fixture( function events( signal?: AbortSignal, initial: T[] = [], - onStart?: (controller: ReadableStreamDefaultController, end: () => void) => void + onStart?: ( + controller: ReadableStreamDefaultController, + end: () => void, + fail: (cause: unknown) => void + ) => void ) { return new ReadableStream({ start(controller) { @@ -95,9 +101,14 @@ function fixture( ended = true; controller.close(); }; + const fail = (cause: unknown) => { + if (ended) return; + ended = true; + controller.error(cause); + }; signal?.addEventListener("abort", end, { once: true }); initial.forEach((event) => controller.enqueue(event)); - onStart?.(controller, end); + onStart?.(controller, end, fail); }, }).values(); } @@ -141,7 +152,8 @@ function fixture( return events( signal, [...messages, { type: "caught-up" }], - (controller, end) => chats.push({ workspaceId, signal, events: controller, end }) + (controller, end, fail) => + chats.push({ workspaceId, signal, events: controller, end, fail }) ); } case "workspace.answerAskUserQuestion": @@ -550,10 +562,12 @@ test("failed credential clearing leaves the session usable and reconnectable bef fireEvent.change(view.getByLabelText("Message"), { target: { value: "Still usable" } }); await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); expect(view.calls.filter((call) => call.path === "workspace.sendMessage")).toHaveLength(1); + // A dropped conversation stream heals on its own; the session itself stays connected. await act(async () => view.chats[0].end()); - await act(async () => fireEvent.click(await view.findByRole("button", { name: "Retry" }))); - await waitFor(() => expect(view.reconnected).toBe(1)); + expect(view.queryByRole("button", { name: "Retry" })).toBeNull(); + act(() => wakeStreams()); await waitFor(() => expect(view.chats).toHaveLength(2)); + expect(view.reconnected).toBe(0); secureStore.clear = async () => {}; fireEvent.click(view.getByRole("button", { name: "Connection settings" })); fireEvent.click(view.getByRole("button", { name: "Disconnect" })); @@ -857,7 +871,7 @@ test("answer recovery does not reappear after an intentional Stop and reconnect" expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); await act(async () => view.chats.at(-1)!.end()); - await act(async () => fireEvent.click(view.getByRole("button", { name: "Retry" }))); + act(() => wakeStreams()); await waitFor(() => expect(view.chats).toHaveLength(2)); expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); expect(callCount(view, "resumeStream")).toBe(1); @@ -890,7 +904,7 @@ test("replayed saved answers offer manual resume, preserve no-op/error retries, expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); expect(callCount(view, "answerAskUserQuestion")).toBe(0); await act(async () => view.chats[0].end()); - await act(async () => fireEvent.click(view.getByRole("button", { name: "Retry" }))); + act(() => wakeStreams()); await waitFor(() => expect(view.chats).toHaveLength(2)); expect(await view.findByRole("button", { name: "Resume agent" })).toBeDefined(); expect(callCount(view, "resumeStream")).toBe(3); @@ -1039,15 +1053,33 @@ test("a competing stream or newer turn prevents recovery from resuming a stale a } }); -test("reconnect cancels the old answer's resume continuation and reconciles pending recovery", async () => { +test("a dropped stream keeps a pending answer's resume continuation, resuming once after the resync", async () => { const view = fixture([question()], true); await view.select("alpha"); const oldAnswer = deferred(); view.setAnswer(() => oldAnswer.promise); await submitAnswer(view); await act(async () => view.chats[0].end()); + act(() => wakeStreams()); + await waitFor(() => expect(view.chats).toHaveLength(2)); + await waitFor(() => expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull()); + // Same client, same session: the answer RPC is still the user's action, so it completes. + await act(async () => oldAnswer.resolve({ success: true })); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(1); +}); + +test("a session reconnect cancels the old answer's resume continuation and reconciles pending recovery", async () => { + const view = fixture([question()], true); + await view.select("alpha"); + const oldAnswer = deferred(); + view.setAnswer(() => oldAnswer.promise); + await submitAnswer(view); + // Only a rejected credential stops the stream from healing itself and exposes Retry. + await act(async () => view.chats[0].fail(new ORPCError("UNAUTHORIZED"))); await act(async () => fireEvent.click(await view.findByRole("button", { name: "Retry" }))); await waitFor(() => expect(view.chats).toHaveLength(2)); + expect(view.reconnected).toBe(1); await act(async () => oldAnswer.resolve({ success: true })); expect(callCount(view, "resumeStream")).toBe(0); view.setAnswer(async () => ({ success: true })); @@ -1361,7 +1393,7 @@ test("interrupt restores the full queue into the existing draft once and keeps i fireEvent.click(view.getByRole("button", { name: "Back to workspaces" })); await view.select("alpha"); await act(async () => view.chats.at(-1)!.end()); - await act(async () => fireEvent.click(view.getByRole("button", { name: "Retry" }))); + act(() => wakeStreams()); await waitFor(() => expect(view.chats).toHaveLength(3)); expect(view.getByLabelText("Message")).toHaveProperty( "value", diff --git a/packages/mobile/src/streams.test.ts b/packages/mobile/src/streams.test.ts new file mode 100644 index 00000000000..c069bdda4bf --- /dev/null +++ b/packages/mobile/src/streams.test.ts @@ -0,0 +1,216 @@ +import "./testDom"; +import { afterEach, expect, test } from "bun:test"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { ORPCError } from "@orpc/client"; +import { STREAM_RETRY_MAX_MS, merge, useStreamsReconnecting, wakeStreams, watch } from "./streams"; + +afterEach(cleanup); + +function source() { + const attempts: Array<{ + signal: AbortSignal; + retries: number; + emit: (event: T) => void; + end: () => void; + fail: (cause: unknown) => void; + }> = []; + let rejectNext: unknown = null; + return { + attempts, + rejectOpen(cause: unknown) { + rejectNext = cause; + }, + open: async (attempt: { signal: AbortSignal; retries: number }): Promise> => { + if (rejectNext) { + const cause = rejectNext; + rejectNext = null; + throw cause; + } + return new ReadableStream({ + start(controller) { + let done = false; + const finish = (cause?: unknown) => { + if (done) return; + done = true; + if (cause === undefined) controller.close(); + else controller.error(cause); + }; + attempts.push({ + signal: attempt.signal, + retries: attempt.retries, + emit: (event) => controller.enqueue(event), + end: () => finish(), + fail: finish, + }); + attempt.signal.addEventListener("abort", () => finish(), { once: true }); + }, + }).values(); + }, + }; +} + +async function settled() { + for (let i = 0; i < 10; i++) await Promise.resolve(); +} + +test("a dropped stream reopens with escalating retries, re-runs onOpen, and stays live after abort", async () => { + const stream = source(); + const lifetime = new AbortController(); + const log: string[] = []; + const done = watch({ + signal: lifetime.signal, + open: stream.open, + onOpen: () => log.push("open"), + onEvent: (event) => log.push(event), + onLost: () => log.push("lost"), + }); + await settled(); + expect(stream.attempts).toHaveLength(1); + stream.attempts[0].emit("a"); + await settled(); + stream.attempts[0].end(); + await settled(); + expect(log).toEqual(["open", "a", "lost"]); + // The retry timer is pending; the wake bus replaces waiting out the backoff. + expect(stream.attempts).toHaveLength(1); + wakeStreams(); + await settled(); + expect(stream.attempts).toHaveLength(2); + expect(stream.attempts[1].retries).toBe(1); + expect(stream.attempts[0].signal.aborted).toBe(true); + stream.attempts[1].fail(new Error("network")); + await settled(); + wakeStreams(); + await settled(); + expect(stream.attempts[2].retries).toBe(2); + expect(log).toEqual(["open", "a", "lost", "open", "lost", "open"]); + lifetime.abort(); + await done; + expect(stream.attempts[2].signal.aborted).toBe(true); + wakeStreams(); + await settled(); + expect(stream.attempts).toHaveLength(3); +}); + +test("a stream that stayed healthy for a full backoff window resets escalation", async () => { + const stream = source(); + const lifetime = new AbortController(); + const now = Date.now; + let clock = now(); + Date.now = () => clock; + try { + const done = watch({ signal: lifetime.signal, open: stream.open, onEvent: () => {} }); + await settled(); + stream.attempts[0].end(); + await settled(); + wakeStreams(); + await settled(); + expect(stream.attempts[1].retries).toBe(1); + stream.attempts[1].fail(new Error("flap")); + await settled(); + wakeStreams(); + await settled(); + expect(stream.attempts[2].retries).toBe(2); + clock += STREAM_RETRY_MAX_MS; + stream.attempts[2].fail(new Error("network")); + await settled(); + wakeStreams(); + await settled(); + expect(stream.attempts[3].retries).toBe(1); + lifetime.abort(); + await done; + } finally { + Date.now = now; + } +}); + +test("a rejected credential ends the watch instead of retrying, whether at open or mid-stream", async () => { + for (const phase of ["open", "stream"] as const) { + const stream = source(); + const lifetime = new AbortController(); + const lost: unknown[] = []; + const done = watch({ + signal: lifetime.signal, + open: stream.open, + onEvent: () => {}, + onLost: (cause) => lost.push(cause), + }); + await settled(); + const cause = new ORPCError("UNAUTHORIZED"); + if (phase === "open") { + stream.attempts[0].end(); + await settled(); + stream.rejectOpen(cause); + wakeStreams(); + } else { + stream.attempts[0].fail(cause); + } + expect(await done.catch((error: unknown) => error)).toBe(cause); + expect(lost).toEqual(phase === "open" ? [expect.any(Error)] : []); + expect(stream.attempts).toHaveLength(1); + lifetime.abort(); + } +}); + +test("health reports reconnecting while any watch waits and clears once all are open or ended", async () => { + const health = renderHook(() => useStreamsReconnecting()); + const first = source(); + const second = source(); + const lifetime = new AbortController(); + const watches = Promise.all( + [first, second].map((stream) => + watch({ signal: lifetime.signal, open: stream.open, onEvent: () => {} }) + ) + ); + await act(settled); + expect(health.result.current).toBe(false); + await act(async () => { + first.attempts[0].end(); + second.attempts[0].end(); + await settled(); + }); + expect(health.result.current).toBe(true); + await act(async () => { + wakeStreams(); + await settled(); + }); + expect(health.result.current).toBe(false); + await act(async () => { + first.attempts[1].end(); + await settled(); + }); + expect(health.result.current).toBe(true); + await act(async () => { + lifetime.abort(); + await watches; + }); + expect(health.result.current).toBe(false); +}); + +test.each(["end", "fail"] as const)( + "merge interleaves sources in arrival order and finishes when any source %ss", + async (ending) => { + const controllers: Array> = []; + const sources = [0, 1].map(() => + new ReadableStream({ + start(controller) { + controllers.push(controller); + }, + }).values() + ); + const merged = merge(sources); + const first = merged.next(); + controllers[1].enqueue("b1"); + controllers[0].enqueue("a1"); + expect((await first).value).toBe("b1"); + expect((await merged.next()).value).toBe("a1"); + const third = merged.next(); + if (ending === "end") { + controllers[0].close(); + expect((await third).done).toBe(true); + } else { + controllers[1].error(new Error("boom")); + expect(await third.catch((cause: unknown) => String(cause))).toContain("boom"); + } + } +); diff --git a/packages/mobile/src/streams.ts b/packages/mobile/src/streams.ts new file mode 100644 index 00000000000..613b2100495 --- /dev/null +++ b/packages/mobile/src/streams.ts @@ -0,0 +1,126 @@ +import { useSyncExternalStore } from "react"; +import { isAuthenticationError } from "./api"; +import { linkedAbortController } from "./useConnection"; + +// Each HTTP subscription is an independent long-lived response, so each one heals +// on its own: a lost stream retries with capped, jittered backoff while unary calls +// keep working. Nothing here is shared with the server, so a cellular handoff costs +// only the streams that were open at that moment. +export const STREAM_RETRY_MIN_MS = 1_000; +export const STREAM_RETRY_MAX_MS = 30_000; + +const wakers = new Set<() => void>(); +/** Skip pending backoff, e.g. when the app returns to the foreground. */ +export function wakeStreams(): void { + for (const wake of [...wakers]) wake(); +} + +const reconnecting = new Set(); +const healthListeners = new Set<() => void>(); +function setReconnecting(stream: object, value: boolean) { + const before = reconnecting.size > 0; + if (value) reconnecting.add(stream); + else reconnecting.delete(stream); + if (reconnecting.size > 0 !== before) for (const listener of [...healthListeners]) listener(); +} +/** True while any subscription in the app is waiting to reconnect. */ +export function useStreamsReconnecting(): boolean { + return useSyncExternalStore( + (listener) => { + healthListeners.add(listener); + return () => healthListeners.delete(listener); + }, + () => reconnecting.size > 0 + ); +} + +function backoff(attempt: number): number { + const cap = Math.min(STREAM_RETRY_MAX_MS, STREAM_RETRY_MIN_MS * 2 ** attempt); + return STREAM_RETRY_MIN_MS + Math.random() * (cap - STREAM_RETRY_MIN_MS); +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + wakers.delete(done); + signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + wakers.add(done); + signal.addEventListener("abort", done, { once: true }); + }); +} + +/** + * Interleave several subscriptions into one. When any of them ends or fails the merged + * stream does too, so a watch over the group reopens them together and re-reads the + * snapshot they jointly guard exactly once. The survivors are not closed here: a + * generator blocked in `next()` cannot be returned, so the sources must share the + * attempt signal that `watch` aborts after every attempt. + */ +export async function* merge(sources: AsyncIterable[]): AsyncGenerator { + const iterators = sources.map((source) => source[Symbol.asyncIterator]()); + const advance = (index: number) => iterators[index].next().then((result) => ({ index, result })); + const pending = iterators.map((_, index) => advance(index)); + while (true) { + const { index, result } = await Promise.race(pending); + if (result.done) return; + yield result.value; + pending[index] = advance(index); + } +} + +export interface WatchOptions { + signal: AbortSignal; + /** Open one attempt. `retries` counts consecutive reopen attempts since the last stable stream. */ + open: (attempt: { signal: AbortSignal; retries: number }) => Promise>; + /** The subscription is registered; read any snapshot it guards here. */ + onOpen?: () => void; + onEvent: (event: T) => void; + /** The stream ended or failed; a retry is scheduled. */ + onLost?: (cause: unknown) => void; +} + +/** + * Consume a subscription until `signal` aborts, reopening it whenever it drops. + * Resolves on abort; rejects only when the server rejects the credential, which no + * retry can fix. + */ +export async function watch(options: WatchOptions): Promise { + const stream = {}; + let retries = 0; + try { + while (!options.signal.aborted) { + const attempt = linkedAbortController(options.signal); + let openedAt: number | null = null; + let cause: unknown; + try { + const events = await options.open({ signal: attempt.signal, retries }); + if (options.signal.aborted) return; + openedAt = Date.now(); + setReconnecting(stream, false); + options.onOpen?.(); + for await (const event of events) { + if (options.signal.aborted) return; + options.onEvent(event); + } + cause = new Error("Stream ended."); + } catch (error) { + cause = error; + } finally { + attempt.abort(); + } + if (options.signal.aborted) return; + if (isAuthenticationError(cause)) throw cause; + // A stream that lived through a whole backoff window was healthy; flapping ones keep escalating. + if (openedAt !== null && Date.now() - openedAt >= STREAM_RETRY_MAX_MS) retries = 0; + setReconnecting(stream, true); + options.onLost?.(cause); + await sleep(backoff(retries++), options.signal); + } + } finally { + setReconnecting(stream, false); + } +} diff --git a/packages/mobile/src/transcript.ts b/packages/mobile/src/transcript.ts index 0171cabe402..df64dc7d01b 100644 --- a/packages/mobile/src/transcript.ts +++ b/packages/mobile/src/transcript.ts @@ -37,6 +37,30 @@ export function createTranscriptState(): TranscriptState { }; } +/** + * Base state for a since-mode replay: the server re-sends every row at or above the + * anchor plus the active stream, so those are dropped here and rebuilt from the + * replay while older rows and pagination survive the reconnect untouched. + */ +export function resumeTranscriptState( + state: TranscriptState, + anchorHistorySequence: number +): TranscriptState { + return { + ...state, + messages: state.messages.filter( + (message) => + message.metadata?.historySequence !== undefined && + message.metadata.historySequence < anchorHistorySequence + ), + streaming: false, + streamingMessageId: null, + streamingWorkspaceId: null, + error: null, + caughtUp: false, + }; +} + function upsert(messages: MuxMessage[], message: MuxMessage): MuxMessage[] { const index = messages.findIndex((item) => item.id === message.id); const next = [...messages]; diff --git a/packages/mobile/src/transportFetch.native.ts b/packages/mobile/src/transportFetch.native.ts new file mode 100644 index 00000000000..53cc5b0f085 --- /dev/null +++ b/packages/mobile/src/transportFetch.native.ts @@ -0,0 +1,8 @@ +import { fetch as expoFetch } from "expo/fetch"; +import type { FetchRequestInit } from "expo/fetch"; + +// React Native's built-in fetch (whatwg-fetch over XHR) buffers whole responses and +// has no `Response.body` stream, so oRPC event iterators would never yield until the +// server closed them. Expo's fetch streams native response bodies incrementally. +export const transportFetch = (url: string, init: RequestInit): Promise => + expoFetch(url, init as FetchRequestInit) as unknown as Promise; diff --git a/packages/mobile/src/transportFetch.ts b/packages/mobile/src/transportFetch.ts new file mode 100644 index 00000000000..c6a577f406a --- /dev/null +++ b/packages/mobile/src/transportFetch.ts @@ -0,0 +1,3 @@ +/** Browsers stream `Response.body`, so the platform fetch serves oRPC event iterators as-is. */ +export const transportFetch = (url: string, init: RequestInit): Promise => + fetch(url, init); diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts index 4cd5ad94335..3a36ab81e8e 100644 --- a/packages/mobile/src/useConversation.test.ts +++ b/packages/mobile/src/useConversation.test.ts @@ -7,6 +7,7 @@ import type { MobileClient } from "./api"; import { getVisibleMessages } from "./transcript"; import type { WorkspaceChatMessage } from "./transcript"; import { useConversation } from "./useConversation"; +import { wakeStreams } from "./streams"; import type { RestoredInput } from "./draft"; import type { SettingsData } from "./settings"; @@ -78,6 +79,7 @@ function fixture( } const restored: RestoredInput[] = []; const chatRequests: AbortSignal[] = []; + const chatInputs: unknown[] = []; const requests: Array<{ input: unknown; signal?: AbortSignal }> = []; const client = createORPCClient({ call: async (path, input, options) => { @@ -120,6 +122,7 @@ function fixture( return reads.agents ? reads.agents() : []; case "workspace.onChat": chatRequests.push(options.signal!); + chatInputs.push(input); return new ReadableStream({ start(controller) { eventController = controller; @@ -170,6 +173,7 @@ function fixture( requests, restored, chatRequests, + chatInputs, policyRequests, policySubscriptions, configSubscriptions, @@ -304,7 +308,9 @@ test("restore and disconnect flush pending text immediately; deleting history ca await view.emit(displayStart); await view.emit(displayDelta("Keep on disconnect")); await view.disconnect(); - expect(view.result.current.error).not.toBeNull(); + // A dropped stream is not an error: the transcript stays, but is read-only until resynced. + expect(view.result.current.error).toBeNull(); + expect(view.result.current.transcript.caughtUp).toBe(false); expect(view.result.current.transcript.messages.at(-1)?.parts[0]).toMatchObject({ text: "Keep on disconnect", }); @@ -642,12 +648,11 @@ test("settings subscriptions precede reads and refresh privacy, routes and provi await view.ready(); expect(view.configSubscriptions).toHaveLength(1); expect(view.providerSubscriptions).toHaveLength(1); - expect(view.settingsOrder.slice(0, 4)).toEqual([ - "config.subscribe", - "providers.subscribe", - "config.listen", - "providers.listen", - ]); + // Both subscriptions are registered before any settings snapshot is read. + expect(view.settingsOrder.slice(0, 2)).toEqual(["config.subscribe", "providers.subscribe"]); + expect(view.settingsOrder.slice(2)).toEqual( + expect.arrayContaining(["config.read", "agents.read", "providers.read"]) + ); config = { ...config, routePriority: ["coder"], @@ -805,3 +810,88 @@ test.each(["workspace", "connection"])( expect(view.result.current.settings?.config).toEqual(current); } ); + +const anchor = { messageId: "10", historySequence: 10, oldestHistorySequence: 9 }; + +test.each(["since", "full"] as const)( + "a dropped conversation resumes from the server cursor and reconciles a %s replay atomically", + async (replay) => { + const view = fixture(); + await waitFor(() => expect(view.chatInputs).toHaveLength(1)); + await view.emit(message(9, "older")); + await view.emit(message(10, "anchor")); + await view.emit(displayStart); + await view.emit({ type: "caught-up", hasOlderHistory: true, cursor: { history: anchor } }); + await waitFor(() => expect(view.result.current.transcript.caughtUp).toBe(true)); + expect(view.result.current.transcript.streaming).toBe(true); + await view.disconnect(); + // The transcript stays on screen, read-only, while the suffix is re-synced. + expect(view.result.current.transcript.caughtUp).toBe(false); + expect(view.result.current.transcript.messages.map((row) => row.id)).toEqual([ + "9", + "10", + "live", + ]); + act(() => wakeStreams()); + await waitFor(() => expect(view.chatInputs).toHaveLength(2)); + expect(view.chatInputs[1]).toEqual({ + workspaceId: "workspace", + mode: { type: "since", cursor: { history: anchor } }, + }); + expect(view.chatRequests[0].aborted).toBe(true); + // Replayed rows are buffered: nothing changes until caught-up arrives. + await view.emit(message(10, "anchor rewritten")); + await view.emit({ ...message(11, "finished while offline"), id: "live" }); + if (replay === "full") await view.emit(message(8, "row the client never had")); + expect(view.result.current.transcript.messages.map((row) => row.parts[0])).toEqual([ + { type: "text", text: "older" }, + { type: "text", text: "anchor" }, + ]); + expect(view.result.current.transcript.messages[2].metadata?.partial).toBe(true); + await view.emit( + replay === "since" + ? { + type: "caught-up", + replay: "since", + cursor: { history: { ...anchor, historySequence: 11, messageId: "live" } }, + } + : { + type: "caught-up", + replay: "full", + downgradeReason: "oldest-mismatch", + hasOlderHistory: false, + } + ); + const { transcript } = view.result.current; + expect(transcript.caughtUp).toBe(true); + expect(transcript.streaming).toBe(false); + expect(transcript.messages.map((row) => [row.id, row.parts[0]])).toEqual( + replay === "since" + ? [ + ["9", { type: "text", text: "older" }], + ["10", { type: "text", text: "anchor rewritten" }], + ["live", { type: "text", text: "finished while offline" }], + ] + : [ + ["8", { type: "text", text: "row the client never had" }], + ["10", { type: "text", text: "anchor rewritten" }], + ["live", { type: "text", text: "finished while offline" }], + ] + ); + // A since replay keeps the pagination the client already knows; a full one is authoritative. + expect(transcript.hasOlderHistory).toBe(replay === "since"); + expect(view.result.current.error).toBeNull(); + } +); + +test("without a server cursor a dropped conversation replays in full from an empty transcript", async () => { + const view = fixture(); + await view.ready(); + await view.disconnect(); + act(() => wakeStreams()); + await waitFor(() => expect(view.chatInputs).toHaveLength(2)); + expect(view.chatInputs[1]).toEqual({ workspaceId: "workspace", mode: { type: "full" } }); + expect(view.result.current.transcript.messages).toEqual([]); + await view.emit(message(12, "fresh")); + expect(view.result.current.transcript.messages.map((row) => row.id)).toEqual(["12"]); +}); diff --git a/packages/mobile/src/useConversation.ts b/packages/mobile/src/useConversation.ts index f1c114006df..93fe89030d6 100644 --- a/packages/mobile/src/useConversation.ts +++ b/packages/mobile/src/useConversation.ts @@ -1,6 +1,12 @@ import { useEffect, useRef, useState } from "react"; import type { MobileClient } from "./api"; -import { applyChatEvent, createTranscriptState, type WorkspaceChatMessage } from "./transcript"; +import { + applyChatEvent, + createTranscriptState, + resumeTranscriptState, + type WorkspaceChatMessage, +} from "./transcript"; +import { merge, watch } from "./streams"; import { MOBILE_STREAM_DISPLAY_BATCH_MS, MOBILE_STREAM_MAX_PENDING_DELTAS, @@ -71,104 +77,120 @@ export function useConversation( { once: true } ); - async function subscribePolicy() { + let policyRequest: AbortController | null = null; + function refreshPolicy() { + // A notification or a fresh subscription invalidates the old snapshot immediately; + // only the newest read may publish. Failures stay closed until the next change. + policyRequest?.abort(); + const request = linkedAbortController(controller.signal); + policyRequest = request; + setPolicy(null); + client.policy + .get(undefined, { signal: request.signal }) + .then( + (next) => { + if (!request.signal.aborted) setPolicy(next); + }, + () => undefined + ) + .finally(() => request.abort()); + } + watch({ + signal: controller.signal, // Subscribe before the initial read so changes during that read are not lost. - const events = await client.policy.onChanged(undefined, { signal: controller.signal }); - async function refresh() { - if (controller.signal.aborted) return; + open: (attempt) => client.policy.onChanged(undefined, { signal: attempt.signal }), + onOpen: refreshPolicy, + onEvent: refreshPolicy, + onLost: () => { + policyRequest?.abort(); setPolicy(null); - try { - const next = await client.policy.get(undefined, { signal: controller.signal }); - if (!controller.signal.aborted) setPolicy(next); - } catch { - // Fail closed, but keep the subscription alive so a later change can heal it. - } - } - await refresh(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Notifications have no payload. - for await (const _ of events) { - if (controller.signal.aborted) return; - await refresh(); - } - if (!controller.signal.aborted) setPolicy(null); - } - subscribePolicy().catch(() => { + }, + }).catch(() => { if (!controller.signal.aborted) setPolicy(null); }); const settingsController = linkedAbortController(controller.signal); let settingsRequest: AbortController | null = null; - async function subscribeSettings() { - // Both subscriptions must be registered before reading privacy/routing settings. - const [configEvents, providerEvents] = await Promise.all([ - client.config.onConfigChanged(undefined, { signal: settingsController.signal }), - client.providers.onConfigChanged(undefined, { signal: settingsController.signal }), - ]); - if (settingsController.signal.aborted) return; - function refresh() { - settingsRequest?.abort(); - const request = linkedAbortController(settingsController.signal); - settingsRequest = request; - // A notification invalidates the old privacy options immediately. Consume - // further notifications while reading, so an older snapshot cannot win. - setSettings(null); - setSettingsError(null); - Promise.all([ - client.config.getConfig(undefined, { signal: request.signal }), - client.agents.list({ workspaceId }, { signal: request.signal }), - client.providers.getConfig(undefined, { signal: request.signal }), - ]) - .then( - ([config, agents, providers]) => { - if (!request.signal.aborted) setSettings({ config, providers, agents }); - }, - () => { - if (!request.signal.aborted) - setSettingsError("Settings unavailable. Retry to reconnect."); - } - ) - .finally(() => request.abort()); - } - const watching = Promise.all( - [configEvents, providerEvents].map(async (events) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Notifications have no payload. - for await (const _ of events) { - if (settingsController.signal.aborted) return; - refresh(); + function refreshSettings() { + settingsRequest?.abort(); + const request = linkedAbortController(settingsController.signal); + settingsRequest = request; + // A notification invalidates the old privacy options immediately. Consume + // further notifications while reading, so an older snapshot cannot win. + setSettings(null); + setSettingsError(null); + Promise.all([ + client.config.getConfig(undefined, { signal: request.signal }), + client.agents.list({ workspaceId }, { signal: request.signal }), + client.providers.getConfig(undefined, { signal: request.signal }), + ]) + .then( + ([config, agents, providers]) => { + if (!request.signal.aborted) setSettings({ config, providers, agents }); + }, + () => { + if (!request.signal.aborted) + setSettingsError("Settings unavailable. Retry to reconnect."); } - if (!settingsController.signal.aborted) throw new Error("Settings disconnected"); - }) - ); - refresh(); - await watching; + ) + .finally(() => request.abort()); } - subscribeSettings().catch(() => { - if (controller.signal.aborted) return; - settingsController.abort(); + function settingsUnavailable() { + settingsRequest?.abort(); setSettings(null); setSettingsError("Settings unavailable. Retry to reconnect."); + } + // Both subscriptions are registered before the snapshot they guard is read; a + // reopened pair re-reads because changes may have happened while it was down. + watch({ + signal: settingsController.signal, + open: async (attempt) => + merge( + await Promise.all([ + client.config.onConfigChanged(undefined, { signal: attempt.signal }), + client.providers.onConfigChanged(undefined, { signal: attempt.signal }), + ]) + ), + onOpen: refreshSettings, + onEvent: refreshSettings, + onLost: settingsUnavailable, + }).catch(() => { + if (controller.signal.aborted) return; + settingsController.abort(); + settingsUnavailable(); }); - async function subscribe() { - const events = await client.workspace.onChat( - { workspaceId, mode: { type: "full" } }, - { signal: controller.signal } - ); - for await (const event of events) { - if (controller.signal.aborted) return; - // This timer throttles display work only. Retain original ordered events; - // every control/tool boundary flushes immediately, not at the next frame. - if (event.type === "stream-delta" || event.type === "reasoning-delta") { - pendingDeltas.push(event); - if (pendingDeltas.length >= MOBILE_STREAM_MAX_PENDING_DELTAS) flush(); - else if (displayTimer === null) - displayTimer = setTimeout(() => flush(), MOBILE_STREAM_DISPLAY_BATCH_MS); - continue; + type Anchor = NonNullable< + NonNullable["cursor"]>["history"] + >; + // The server's reconnect anchor from the last caught-up. With one, a dropped stream + // resumes since that row: the visible transcript stays put while the replayed suffix + // is buffered, then the two are reconciled atomically at caught-up. Without one, + // the replay is full and streams straight into a fresh transcript. + let anchor: Anchor | null = null; + let resume: { anchor: Anchor; buffered: WorkspaceChatMessage[] } | null = null; + watch({ + signal: controller.signal, + open: (attempt) => { + flush(); + resume = anchor ? { anchor, buffered: [] } : null; + if (!resume) { + historyCursor.current = null; + setTranscript(createTranscriptState()); } + return client.workspace.onChat( + { + workspaceId, + mode: resume ? { type: "since", cursor: { history: resume.anchor } } : { type: "full" }, + }, + { signal: attempt.signal } + ); + }, + onEvent: (event) => { // This is a one-shot queue handoff, not replayable transcript state. Consume - // it here so React rerenders cannot restore it twice or restart the socket. + // it here so React rerenders cannot restore it twice or restart the stream. if (event.type === "restore-to-input") { flush(); if (event.workspaceId === workspaceId) restore.current?.(event); - continue; + return; } if (event.type === "delete") { // A page read before a truncate must not resurrect deleted history. @@ -177,21 +199,54 @@ export function useConversation( historyCursor.current = null; setLoadingOlder(false); } + if (event.type === "caught-up") { + anchor = event.cursor?.history ?? null; + if (resume) { + const { anchor: requested, buffered } = resume; + resume = null; + // The server may downgrade a since replay to a full one; then the buffered + // events describe the whole transcript rather than a suffix. + const downgraded = event.replay !== "since"; + if (downgraded) historyCursor.current = null; + setTranscript((current) => + [...buffered, event].reduce( + applyChatEvent, + downgraded + ? createTranscriptState() + : resumeTranscriptState(current, requested.historySequence) + ) + ); + return; + } + flush(event); + return; + } + if (resume) { + resume.buffered.push(event); + return; + } + // This timer throttles display work only. Retain original ordered events; + // every control/tool boundary flushes immediately, not at the next frame. + if (event.type === "stream-delta" || event.type === "reasoning-delta") { + pendingDeltas.push(event); + if (pendingDeltas.length >= MOBILE_STREAM_MAX_PENDING_DELTAS) flush(); + else if (displayTimer === null) + displayTimer = setTimeout(() => flush(), MOBILE_STREAM_DISPLAY_BATCH_MS); + return; + } flush(event); - } - if (!controller.signal.aborted) - throw new Error( - "Conversation disconnected. Retry to reload the full history before sending." - ); - } - subscribe().catch((cause: unknown) => { + }, + onLost: () => { + // Sending needs a synced transcript; hold it read-only until the stream is back. + flush(); + resume = null; + setTranscript((current) => ({ ...current, caughtUp: false })); + }, + }).catch(() => { + // Only a rejected credential ends the watch; transient failures retry silently. flush(); if (!controller.signal.aborted) - setError( - cause instanceof Error - ? cause.message - : "Could not load the conversation. Retry to reconnect." - ); + setError("The server rejected this session. Retry to reconnect or sign in again."); }); return () => { controller.abort(); diff --git a/packages/mobile/src/useProjects.test.ts b/packages/mobile/src/useProjects.test.ts index 40bd9b93dcc..d6a53006c03 100644 --- a/packages/mobile/src/useProjects.test.ts +++ b/packages/mobile/src/useProjects.test.ts @@ -6,6 +6,7 @@ import { SCRATCH_PROJECT_CONFIG_KEY } from "../../../src/common/constants/scratc import type { FrontendWorkspaceMetadata } from "../../../src/common/types/workspace"; import type { MobileClient } from "./api"; import { useProjects, type Projects } from "./useProjects"; +import { wakeStreams } from "./streams"; afterEach(cleanup); @@ -228,33 +229,38 @@ test.each([ { kind: "metadata", ending: "end" }, { kind: "metadata", ending: "fail" }, ] as const)( - "a $kind subscription $ending aborts pending work and retry reconnects", + "a $kind subscription $ending keeps the catalog, reopens on its own and re-reads its snapshot", async ({ kind, ending }) => { const view = mount(); await view.ready(); const { source } = view; - expect(source.config).toHaveLength(1); - await act(async () => source.config[0].emit()); - await waitFor(() => expect(source.projectReads).toHaveLength(2)); + const other = kind === "config" ? "metadata" : "config"; await act(async () => source[kind][0][ending]()); - expect(view.result.current.error).not.toBeNull(); - expect(view.result.current.loading).toBe(false); - expect(source.projectReads[1].signal.aborted).toBe(true); - expect(source.metadata[0].signal.aborted).toBe(true); - await act(async () => source.projectReads[1].resolve(catalog("late after disconnect"))); + expect(view.result.current.error).toBeNull(); expect(view.result.current.projects).toEqual(catalog("initial")); - act(() => view.result.current.retry()); - await waitFor(() => expect(source.projectReads).toHaveLength(3)); + expect(source[other][0].signal.aborted).toBe(false); + act(() => wakeStreams()); + await waitFor(() => expect(source[kind]).toHaveLength(2)); + // The reopened subscription is registered before its guarded snapshot is re-read. + const reads = kind === "config" ? source.projectReads : source.workspaceReads; + await waitFor(() => expect(reads).toHaveLength(2)); + expect(source.order.slice(-2)).toEqual([ + kind === "config" ? "config.onConfigChanged" : "workspace.onMetadata", + kind === "config" ? "projects.list" : "workspace.list", + ]); await act(async () => { - source.projectReads[2].resolve(catalog("healed")); - source.workspaceReads[1].resolve([workspace]); + if (kind === "config") source.projectReads[1].resolve(catalog("healed")); + else source.workspaceReads[1].resolve([{ ...workspace, name: "healed" }]); }); expect(view.result.current.error).toBeNull(); - expect(view.result.current.projects).toEqual(catalog("healed")); + expect( + kind === "config" ? view.result.current.projects : view.result.current.workspaces + ).toEqual(kind === "config" ? catalog("healed") : [{ ...workspace, name: "healed" }]); + expect(source[other]).toHaveLength(1); } ); -test("a failed refresh exposes retry without losing existing catalog or applying a later stale success", async () => { +test("a failed refresh exposes retry without losing the catalog, and the next read of that catalog clears it", async () => { const view = mount(); await view.ready(); const { source } = view; @@ -262,22 +268,31 @@ test("a failed refresh exposes retry without losing existing catalog or applying await act(async () => source.config[0].emit()); await waitFor(() => expect(source.projectReads).toHaveLength(2)); await act(async () => source.projectReads[1].reject(new Error("refresh failed"))); - expect(view.result.current.error).not.toBeNull(); + expect(view.result.current.error).toBe("refresh failed"); expect(view.result.current.projects).toEqual(catalog("initial")); - expect(source.config[0].signal.aborted).toBe(true); - expect(source.metadata[0].signal.aborted).toBe(true); + // Subscriptions stay live: a later invalidation can heal the catalog without Retry. + expect(source.config[0].signal.aborted).toBe(false); + expect(source.metadata[0].signal.aborted).toBe(false); + await act(async () => source.metadata[0].emit({ workspaceId: "w", metadata: null })); + expect(view.result.current.workspaces).toEqual([]); + await act(async () => source.config[0].emit()); + await waitFor(() => expect(source.projectReads).toHaveLength(3)); + await act(async () => source.projectReads[2].resolve(catalog("healed"))); + expect(view.result.current.error).toBeNull(); + expect(view.result.current.projects).toEqual(catalog("healed")); }); -test("initial snapshot failure cancels the other read and retry restores both catalogs", async () => { +test("one catalog's failure is not cleared by the other catalog's success, and retry restores both", async () => { const view = mount(); const { source } = view; await waitFor(() => expect(source.workspaceReads).toHaveLength(1)); await act(async () => source.workspaceReads[0].reject(new Error("workspace read failed"))); expect(view.result.current.loading).toBe(false); - expect(view.result.current.error).not.toBeNull(); - expect(source.projectReads[0].signal.aborted).toBe(true); - await act(async () => source.projectReads[0].resolve(catalog("late initial result"))); - expect(view.result.current.projects).toEqual([]); + expect(view.result.current.error).toBe("workspace read failed"); + expect(source.projectReads[0].signal.aborted).toBe(false); + await act(async () => source.projectReads[0].resolve(catalog("initial result"))); + expect(view.result.current.projects).toEqual(catalog("initial result")); + expect(view.result.current.error).toBe("workspace read failed"); act(() => view.result.current.retry()); await waitFor(() => expect(source.projectReads).toHaveLength(2)); await act(async () => { @@ -289,3 +304,19 @@ test("initial snapshot failure cancels the other read and retry restores both ca expect(view.result.current.projects).toEqual(catalog("retried")); expect(view.result.current.workspaces).toEqual([workspace]); }); + +test("metadata held during a failed snapshot read is applied to the retained list", async () => { + const view = mount(); + await view.ready(); + const { source } = view; + await act(async () => source.metadata[0].end()); + act(() => wakeStreams()); + await waitFor(() => expect(source.workspaceReads).toHaveLength(2)); + await act(async () => + source.metadata[1].emit({ workspaceId: "w", metadata: { ...workspace, title: "during read" } }) + ); + expect(view.result.current.workspaces[0].title).toBeUndefined(); + await act(async () => source.workspaceReads[1].reject(new Error("relist failed"))); + expect(view.result.current.error).toBe("relist failed"); + expect(view.result.current.workspaces[0].title).toBe("during read"); +}); diff --git a/packages/mobile/src/useProjects.ts b/packages/mobile/src/useProjects.ts index 49e1f750c83..da367b54f4a 100644 --- a/packages/mobile/src/useProjects.ts +++ b/packages/mobile/src/useProjects.ts @@ -4,8 +4,14 @@ import type { FrontendWorkspaceMetadata } from "../../../src/common/types/worksp import { isWorkspaceArchived } from "../../../src/common/utils/archive"; import { SCRATCH_PROJECT_CONFIG_KEY } from "../../../src/common/constants/scratch"; import { linkedAbortController } from "./useConnection"; +import { watch } from "./streams"; export type Projects = Awaited>; +type MetadataEvent = + Awaited> extends AsyncIterable + ? Event + : never; + export function useProjects(client: MobileClient, signal: AbortSignal) { const [projects, setProjects] = useState([]); const [workspaces, setWorkspaces] = useState([]); @@ -20,84 +26,109 @@ export function useProjects(client: MobileClient, signal: AbortSignal) { setLoading(false); return; } - let projectRequest: AbortController | null = null; - let projectsLoaded = false; - let workspacesLoaded = false; - function finishLoading() { - if (projectsLoaded && workspacesLoaded) setLoading(false); + const loaded = { projects: false, workspaces: false }; + let failed: object | null = null; + /** + * One snapshot reader per catalog. Invalidations keep arriving while a read is + * pending, and only the newest read may publish. A failed read exposes retry + * without discarding the catalog that is already on screen; the next successful + * read of the same catalog clears that error. + */ + function reader( + read: (signal: AbortSignal) => Promise, + apply: (value: T) => void, + onFailure?: () => void + ) { + const self = {}; + let pending: AbortController | null = null; + return () => { + if (controller.signal.aborted) return; + pending?.abort(); + const request = linkedAbortController(controller.signal); + pending = request; + read(request.signal) + .then( + (value) => { + if (request.signal.aborted) return; + apply(value); + if (failed === self) { + failed = null; + setError(null); + } + if (loaded.projects && loaded.workspaces) setLoading(false); + }, + (cause: unknown) => { + if (request.signal.aborted) return; + failed = self; + setError( + cause instanceof Error ? cause.message : "Could not load projects or workspaces." + ); + setLoading(false); + onFailure?.(); + } + ) + .finally(() => request.abort()); + }; } - function fail(cause: unknown) { - if (controller.signal.aborted) return; - controller.abort(); - setError(cause instanceof Error ? cause.message : "Could not load projects or workspaces."); - setLoading(false); + const refreshProjects = reader( + (signal) => client.projects.list(undefined, { signal }), + (projectList) => { + // Scratch chats have their own creation path, not a git worktree target. + setProjects(projectList.filter(([path]) => path !== SCRATCH_PROJECT_CONFIG_KEY)); + loaded.projects = true; + } + ); + function applyMetadata(event: MetadataEvent) { + setWorkspaces((current) => { + const rest = current.filter((workspace) => workspace.id !== event.workspaceId); + return event.metadata && + !isWorkspaceArchived(event.metadata.archivedAt, event.metadata.unarchivedAt) + ? [...rest, event.metadata] + : rest; + }); } - function refreshProjects() { - if (controller.signal.aborted) return; - // Keep consuming invalidations while reading: an older response must never - // overwrite a newer catalog used by the navigator and workspace picker. - projectRequest?.abort(); - const request = linkedAbortController(controller.signal); - projectRequest = request; - client.projects - .list(undefined, { signal: request.signal }) - .then( - (projectList) => { - if (request.signal.aborted) return; - // Scratch chats have their own creation path, not a git worktree target. - setProjects(projectList.filter(([path]) => path !== SCRATCH_PROJECT_CONFIG_KEY)); - projectsLoaded = true; - finishLoading(); - }, - (cause: unknown) => { - if (!request.signal.aborted) fail(cause); - } - ) - .finally(() => request.abort()); + // Events observed while a snapshot is in flight are newer than the snapshot's + // view, so they are held back and applied on top of it. + let heldMetadata: MetadataEvent[] | null = null; + function releaseMetadata() { + for (const event of heldMetadata ?? []) applyMetadata(event); + heldMetadata = null; } - async function load() { - // Register both sources before reading either snapshot so changes cannot be missed. - const [events, configEvents] = await Promise.all([ - client.workspace.onMetadata(undefined, { signal: controller.signal }), - client.config.onConfigChanged(undefined, { signal: controller.signal }), - ]); + const refreshWorkspaces = reader( + (signal) => client.workspace.list(undefined, { signal }), + (workspaceList) => { + setWorkspaces(workspaceList); + loaded.workspaces = true; + releaseMetadata(); + }, + releaseMetadata + ); + // Each subscription is registered before the snapshot it guards is read, so + // changes during the read cannot be missed; a reopened one re-reads because + // changes may have happened while it was down. + Promise.all([ + watch({ + signal: controller.signal, + open: (attempt) => client.workspace.onMetadata(undefined, { signal: attempt.signal }), + onOpen: () => { + heldMetadata = []; + refreshWorkspaces(); + }, + onEvent: (event) => (heldMetadata ? heldMetadata.push(event) : applyMetadata(event)), + }), + watch({ + signal: controller.signal, + open: (attempt) => client.config.onConfigChanged(undefined, { signal: attempt.signal }), + onOpen: refreshProjects, + onEvent: refreshProjects, + }), + ]).catch(() => { + // Only a rejected credential ends the watches; everything else retries. if (controller.signal.aborted) return; - const watching = Promise.all([ - (async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Notifications have no payload. - for await (const _ of configEvents) { - if (controller.signal.aborted) return; - refreshProjects(); - } - if (!controller.signal.aborted) - throw new Error("Project updates disconnected. Refresh the list to reconnect."); - })(), - (async () => { - const workspaceList = await client.workspace.list(undefined, { - signal: controller.signal, - }); - if (controller.signal.aborted) return; - setWorkspaces(workspaceList); - workspacesLoaded = true; - finishLoading(); - for await (const event of events) { - if (controller.signal.aborted) return; - setWorkspaces((current) => { - const rest = current.filter((workspace) => workspace.id !== event.workspaceId); - return event.metadata && - !isWorkspaceArchived(event.metadata.archivedAt, event.metadata.unarchivedAt) - ? [...rest, event.metadata] - : rest; - }); - } - if (!controller.signal.aborted) - throw new Error("Workspace updates disconnected. Refresh the list to reconnect."); - })(), - ]); - refreshProjects(); - await watching; - } - load().catch(fail); + setError("The server rejected this session. Retry to reconnect or sign in again."); + setLoading(false); + controller.abort(); + }); return () => controller.abort(); }, [client, signal, generation]); return { diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 6da20e63213..dd1bc27d62d 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -7091,7 +7091,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "The token grants access to the server, including its code-execution capabilities. Treat it like a password. Native builds save connection details in device secure storage. The web preview keeps them in memory only; refreshing requires entering them again. Disconnect clears the saved native connection.", "", - "Before opening a WebSocket, the companion exchanges the token in an HTTP Authorization header for a short-lived, single-use upgrade ticket. The long-lived token is not included in the WebSocket URL or subprotocols. Older servers without ticket support must be updated; there is no credential-URL fallback.", + "The companion talks to the server over plain HTTP oRPC: every call is its own request carrying the token in an `Authorization` header, and each live subscription (conversation events, workspace metadata, config/provider/policy changes) is its own streamed response. There is no shared socket or per-connection state, so a cellular handoff or a backgrounded app only interrupts the streams that were open; each one reconnects on its own with capped backoff, and a dropped conversation resumes from the server's cursor rather than replaying the whole transcript. Unary calls and mutations are never retried automatically. The token never appears in a URL.", "", "All non-loopback endpoints—including private LAN, ULA, and link-local addresses—require HTTPS. HTTP is accepted only for `localhost`, IPv4 loopback (`127.0.0.0/8`), or IPv6 loopback (`[::1]`) for development, with a plaintext-token warning. A phone's `localhost` refers to the phone, not your development computer: use a trusted HTTPS endpoint to reach that computer from a device.", "", @@ -7127,7 +7127,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "XUM_MOBILE_ENDPOINT=http://127.0.0.1:3000 make mobile-preview", "```", "", - "The preview is development tooling, not a general-purpose public proxy. It intentionally runs under Node: Bun's Node HTTP compatibility can stall forwarded WebSocket frames.", + "The preview is development tooling, not a general-purpose public proxy. It runs under Node and forwards streamed subscription responses without buffering.", "", "## Native development", "", @@ -7184,7 +7184,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "3. Send a message, observe streamed text/tools/reasoning, and interrupt a running turn.", "4. Change agent/model settings, switch workspaces, and verify conversations do not mix.", "5. Open Changes and Settings; disconnect and confirm credentials are not retained in browser storage.", - "6. Drop the connection, retry, and verify authoritative history reloads before sending is enabled.", + "6. Drop the connection mid-conversation: the transcript stays visible read-only while “Reconnecting…” shows, then resumes in place once the server is reachable again, and sending is re-enabled only after that resync.", "7. Capture screenshots and a short recording of the walkthrough, including narrow layouts and any failure/recovery steps.", "", "## Can Xum run inside the native JS engine?", From 942bf86417dfa7f02be7a3f900e5b6b9434bd56a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 09:14:03 +0000 Subject: [PATCH 82/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20hold=20two?= =?UTF-8?q?=20HTTP=20streams=20per=20conversation=20instead=20of=20six?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RN Web dogfooding showed six concurrent SSE subscriptions exhausting the browser's HTTP/1.1 per-host pool, so the unary reads of changed snapshots never completed. Add `server.onChanged`, one server stream fanning in config, provider, policy and workspace-metadata changes, shared client-side by every consumer of a client; a conversation now holds that stream plus its own chat stream. The preview proxy also ends the browser response when the upstream stream dies so clients see the drop. --- _Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking: `high` • Cost: `$1704.94`_ --- docs/integrations/mobile-app.md | 2 +- packages/mobile/scripts/preview.test.ts | 27 +++++ packages/mobile/scripts/preview.ts | 7 ++ packages/mobile/src/api.ts | 5 +- .../mobile/src/screens/CreateWorkspace.tsx | 9 +- .../mobile/src/screens/forms.behavior.tsx | 22 ++-- .../mobile/src/screens/session.behavior.tsx | 33 +++-- .../mobile/src/screens/streaming.behavior.tsx | 6 +- packages/mobile/src/streams.test.ts | 110 ++++++++++++----- packages/mobile/src/streams.ts | 102 +++++++++++++--- packages/mobile/src/useConversation.test.ts | 84 ++++++------- packages/mobile/src/useConversation.ts | 51 +++----- packages/mobile/src/useProjects.test.ts | 114 +++++++++--------- packages/mobile/src/useProjects.ts | 45 +++---- src/common/orpc/schemas/api.ts | 23 ++++ src/node/orpc/router.ts | 5 + src/node/orpc/routerSubscriptions.test.ts | 49 +++++++- src/node/orpc/routerSubscriptions.ts | 24 ++++ .../builtInSkillContent.generated.ts | 2 +- 19 files changed, 469 insertions(+), 251 deletions(-) diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md index 4a823d4445e..31c3bff787f 100644 --- a/docs/integrations/mobile-app.md +++ b/docs/integrations/mobile-app.md @@ -15,7 +15,7 @@ During development, run the mobile client and server from the same branch/revisi The token grants access to the server, including its code-execution capabilities. Treat it like a password. Native builds save connection details in device secure storage. The web preview keeps them in memory only; refreshing requires entering them again. Disconnect clears the saved native connection. -The companion talks to the server over plain HTTP oRPC: every call is its own request carrying the token in an `Authorization` header, and each live subscription (conversation events, workspace metadata, config/provider/policy changes) is its own streamed response. There is no shared socket or per-connection state, so a cellular handoff or a backgrounded app only interrupts the streams that were open; each one reconnects on its own with capped backoff, and a dropped conversation resumes from the server's cursor rather than replaying the whole transcript. Unary calls and mutations are never retried automatically. The token never appears in a URL. +The companion talks to the server over plain HTTP oRPC: every call is its own request carrying the token in an `Authorization` header, and live updates arrive on streamed responses—one shared change stream for workspace metadata and config/provider/policy changes, plus one conversation stream per open conversation. Holding at most two streams matters because browsers and mobile HTTP stacks cap HTTP/1.1 connections per host at about six. There is no shared socket or per-connection state, so a cellular handoff or a backgrounded app only interrupts the streams that were open; each one reconnects on its own with capped backoff, and a dropped conversation resumes from the server's cursor rather than replaying the whole transcript. Unary calls and mutations are never retried automatically. The token never appears in a URL. All non-loopback endpoints—including private LAN, ULA, and link-local addresses—require HTTPS. HTTP is accepted only for `localhost`, IPv4 loopback (`127.0.0.0/8`), or IPv6 loopback (`[::1]`) for development, with a plaintext-token warning. A phone's `localhost` refers to the phone, not your development computer: use a trusted HTTPS endpoint to reach that computer from a device. diff --git a/packages/mobile/scripts/preview.test.ts b/packages/mobile/scripts/preview.test.ts index b82bd0d4330..4a9f4de2576 100644 --- a/packages/mobile/scripts/preview.test.ts +++ b/packages/mobile/scripts/preview.test.ts @@ -110,3 +110,30 @@ test("streams a long-lived subscription response through without buffering", asy expect(decoder.decode((await reader.read()).value)).toContain("event: message"); await reader.cancel(); }); + +test("a subscription response ends for the browser when the upstream dies mid-stream", async () => { + let upstreamResponse!: http.ServerResponse; + const endpoint = await listen( + http.createServer((_req, res) => { + upstreamResponse = res; + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write(": open\n\n"); + }) + ); + const preview = await listen(createPreviewServer({ endpoint, origin })); + const response = await fetch(`${preview}/__xum/orpc/workspace/onChat`, { + method: "POST", + headers: { host, origin }, + }); + const reader = response.body!.getReader(); + await reader.read(); + upstreamResponse.destroy(); + const outcome = await Promise.race([ + reader.read().then( + (result) => (result.done ? "ended" : "data"), + () => "errored" + ), + new Promise((resolve) => setTimeout(() => resolve("still open"), 2_000)), + ]); + expect(["ended", "errored"]).toContain(outcome); +}); diff --git a/packages/mobile/scripts/preview.ts b/packages/mobile/scripts/preview.ts index 95735b457f1..826a9348e77 100644 --- a/packages/mobile/scripts/preview.ts +++ b/packages/mobile/scripts/preview.ts @@ -19,6 +19,13 @@ export function createPreviewServer(options: PreviewOptions) { // Subscriptions are long-lived streamed responses; the server's keep-alive comments // arrive well within this idle bound, so only a dead upstream trips it. const proxy = httpProxy.createProxyServer({ changeOrigin: true, proxyTimeout: 30_000 }); + proxy.on("proxyRes", (proxyRes, _req, res) => { + // http-proxy leaves the browser's streamed response open when the upstream dies + // mid-stream; the client must see the drop to reconnect. + proxyRes.once("close", () => { + if (!res.writableEnded) res.destroy(); + }); + }); const allowed = (req: http.IncomingMessage) => req.headers.host === origin.host && (!req.headers.origin || req.headers.origin === origin.origin) && diff --git a/packages/mobile/src/api.ts b/packages/mobile/src/api.ts index 7edcdf2f7b4..f526c09ba57 100644 --- a/packages/mobile/src/api.ts +++ b/packages/mobile/src/api.ts @@ -15,7 +15,10 @@ type SchemaClient = T extends { ? Client, InferSchemaOutput, Error> : { [K in keyof T]: SchemaClient }; export type MobileClient = SchemaClient< - Pick + Pick< + typeof schemas, + "projects" | "workspace" | "providers" | "agents" | "config" | "policy" | "server" + > >; export interface MobileConnection { client: MobileClient; diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx index eebab683498..241f69a5496 100644 --- a/packages/mobile/src/screens/CreateWorkspace.tsx +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -8,7 +8,7 @@ import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/wor import { Button, Field, Loading, Notice, Sheet } from "../components/Controls"; import { colors, layout, radii, spacing, typography } from "../theme"; import { linkedAbortController } from "../useConnection"; -import { watch } from "../streams"; +import { watchServerChanges } from "../streams"; import { resolveWorkspaceCreationScope } from "../../../../src/common/utils/subProjects"; import type { PolicyGetResponse } from "../../../../src/common/orpc/types"; import { RUNTIME_MODE } from "../../../../src/common/types/runtime"; @@ -108,11 +108,12 @@ export function CreateWorkspace(props: { ) .finally(() => next.abort()); } - watch({ + watchServerChanges(props.client, { signal: lifetime.signal, - open: (attempt) => props.client.policy.onChanged(undefined, { signal: attempt.signal }), onOpen: refresh, - onEvent: refresh, + onEvent: (event) => { + if (event.type === "policy") refresh(); + }, onLost: unavailable, }).catch(unavailable); return () => { diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx index 10efa23f337..a73767770d3 100644 --- a/packages/mobile/src/screens/forms.behavior.tsx +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -386,8 +386,8 @@ function createFormClient( return createORPCClient({ call: async (path, input, options) => { if (path.join(".") === "policy.get") return unrestrictedCreationPolicy; - if (path.join(".") === "policy.onChanged") - return new ReadableStream({ + if (path.join(".") === "server.onChanged") + return new ReadableStream({ start(controller) { options.signal?.addEventListener("abort", () => controller.close(), { once: true }); }, @@ -846,9 +846,9 @@ function creationPolicyClient() { const client = createORPCClient({ call: async (path, input, options) => { const method = path.join("."); - if (method === "policy.onChanged") { + if (method === "server.onChanged") { order.push(method); - return new ReadableStream({ + return new ReadableStream<{ type: "policy" }>({ start(controller) { let closed = false; const close = () => { @@ -857,7 +857,11 @@ function creationPolicyClient() { controller.close(); } }; - subscriptions.push({ signal: options.signal, emit: () => controller.enqueue(), close }); + subscriptions.push({ + signal: options.signal, + emit: () => controller.enqueue({ type: "policy" }), + close, + }); options.signal?.addEventListener("abort", close, { once: true }); }, }).values(); @@ -924,7 +928,7 @@ test("creation policy subscribes before reading, fails closed, cancels stale rea fireEvent.keyDown(title, { key: "Enter", keyCode: 13 }); expect(fixture.calls).toHaveLength(0); await waitFor(() => expect(fixture.reads).toHaveLength(1)); - expect(fixture.order).toEqual(["policy.onChanged", "policy.get"]); + expect(fixture.order).toEqual(["server.onChanged", "policy.get"]); fixture.setRead(() => Promise.reject(new Error("policy unavailable"))); await act(async () => { fixture.subscriptions[0].emit(); @@ -1194,10 +1198,8 @@ test.each([ switch (path.join(".")) { case "policy.get": return pickerData.policy; - case "config.onConfigChanged": - case "providers.onConfigChanged": - case "policy.onChanged": - return new ReadableStream({ + case "server.onChanged": + return new ReadableStream({ start(controller) { request.signal?.addEventListener("abort", () => controller.close(), { once: true }); }, diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index 827630537ae..4f147a565c6 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -45,9 +45,11 @@ function fixture( initialWorkspaces: FrontendWorkspaceMetadata[] = workspaces ) { let workspaceList = initialWorkspaces; - const metadataEvents: Array< - ReadableStreamDefaultController<{ workspaceId: string; metadata: FrontendWorkspaceMetadata }> - > = []; + type ChangeEvent = + Awaited> extends AsyncIterable + ? Event + : never; + const changeEvents: ReadableStreamDefaultController[] = []; Object.defineProperty(document.documentElement, "clientWidth", { configurable: true, value: wide ? 1200 : 375, @@ -72,11 +74,8 @@ function fixture( { id: "scout", name: "Scout", uiSelectable: true }, { id: "plan", name: "Plan", uiSelectable: true }, ].map((agent) => ({ ...agent, scope: "built-in", subagentRunnable: true })); - const configEvents: ReadableStreamDefaultController[] = []; - const providerEvents: ReadableStreamDefaultController[] = []; let configRead = async () => config; let policy = initialPolicy; - const policyEvents: ReadableStreamDefaultController[] = []; let closed = 0; let reconnected = 0; let disconnected = 0; @@ -117,8 +116,8 @@ function fixture( const name = path.join("."); calls.push({ path: name, input, signal: options.signal }); switch (name) { - case "workspace.onMetadata": - return events(options.signal, [], (controller) => metadataEvents.push(controller)); + case "server.onChanged": + return events(options.signal, [], (controller) => changeEvents.push(controller)); case "workspace.list": return workspaceList; case "projects.list": @@ -126,12 +125,6 @@ function fixture( case "policy.get": if (policy instanceof Error) throw policy; return policy; - case "policy.onChanged": - return events(options.signal, [], (controller) => policyEvents.push(controller)); - case "config.onConfigChanged": - return events(options.signal, [], (controller) => configEvents.push(controller)); - case "providers.onConfigChanged": - return events(options.signal, [], (controller) => providerEvents.push(controller)); case "config.getConfig": return configRead(); case "providers.getConfig": @@ -228,29 +221,31 @@ function fixture( workspaceList = workspaceList.map((workspace) => workspace.id === metadata.id ? metadata : workspace ); - await act(async () => metadataEvents.at(-1)!.enqueue({ workspaceId: metadata.id, metadata })); + await act(async () => + changeEvents.at(-1)!.enqueue({ type: "metadata", workspaceId: metadata.id, metadata }) + ); }, get agents() { return agents; }, async updateAgents(next: SettingsData["agents"]) { agents = next; - await act(async () => configEvents.at(-1)!.enqueue()); + await act(async () => changeEvents.at(-1)!.enqueue({ type: "config" })); }, setConfigRead(read: typeof configRead) { configRead = read; }, async updateConfig(next: SettingsData["config"]) { config = next; - await act(async () => configEvents.at(-1)!.enqueue()); + await act(async () => changeEvents.at(-1)!.enqueue({ type: "config" })); }, async updateProviders(next: SettingsData["providers"]) { providers = next; - await act(async () => providerEvents.at(-1)!.enqueue()); + await act(async () => changeEvents.at(-1)!.enqueue({ type: "providers" })); }, async updatePolicy(next: Policy) { policy = next; - await act(async () => policyEvents.at(-1)!.enqueue()); + await act(async () => changeEvents.at(-1)!.enqueue({ type: "policy" })); }, async emit(event: WorkspaceChatMessage) { await act(async () => chats.at(-1)!.events.enqueue(event)); diff --git a/packages/mobile/src/screens/streaming.behavior.tsx b/packages/mobile/src/screens/streaming.behavior.tsx index b6a94562d2d..384abd8b13a 100644 --- a/packages/mobile/src/screens/streaming.behavior.tsx +++ b/packages/mobile/src/screens/streaming.behavior.tsx @@ -25,10 +25,8 @@ test("120 separately delivered deltas render Markdown once per display flush whi return [{ id: "exec", name: "Exec", uiSelectable: true }]; case "policy.get": return { source: "none", status: { state: "disabled" }, policy: null }; - case "config.onConfigChanged": - case "providers.onConfigChanged": - case "policy.onChanged": - return new ReadableStream({ + case "server.onChanged": + return new ReadableStream({ start(controller) { options.signal?.addEventListener("abort", () => controller.close(), { once: true }); }, diff --git a/packages/mobile/src/streams.test.ts b/packages/mobile/src/streams.test.ts index c069bdda4bf..98ecf89b785 100644 --- a/packages/mobile/src/streams.test.ts +++ b/packages/mobile/src/streams.test.ts @@ -2,7 +2,15 @@ import "./testDom"; import { afterEach, expect, test } from "bun:test"; import { act, cleanup, renderHook } from "@testing-library/react"; import { ORPCError } from "@orpc/client"; -import { STREAM_RETRY_MAX_MS, merge, useStreamsReconnecting, wakeStreams, watch } from "./streams"; +import { + STREAM_RETRY_MAX_MS, + useStreamsReconnecting, + wakeStreams, + watch, + watchServerChanges, +} from "./streams"; +import type { MobileClient } from "./api"; +import type { ServerChangeEvent } from "../../../src/common/orpc/schemas/api"; afterEach(cleanup); @@ -187,30 +195,76 @@ test("health reports reconnecting while any watch waits and clears once all are expect(health.result.current).toBe(false); }); -test.each(["end", "fail"] as const)( - "merge interleaves sources in arrival order and finishes when any source %ss", - async (ending) => { - const controllers: Array> = []; - const sources = [0, 1].map(() => - new ReadableStream({ - start(controller) { - controllers.push(controller); - }, - }).values() - ); - const merged = merge(sources); - const first = merged.next(); - controllers[1].enqueue("b1"); - controllers[0].enqueue("a1"); - expect((await first).value).toBe("b1"); - expect((await merged.next()).value).toBe("a1"); - const third = merged.next(); - if (ending === "end") { - controllers[0].close(); - expect((await third).done).toBe(true); - } else { - controllers[1].error(new Error("boom")); - expect(await third.catch((cause: unknown) => String(cause))).toContain("boom"); - } - } -); +function changesClient() { + const stream = source(); + const client = { + server: { + onChanged: (_input: undefined, options: { signal?: AbortSignal }) => + stream.open({ signal: options.signal!, retries: 0 }), + }, + } as unknown as Pick; + return { client, stream }; +} + +test("consumers share one change stream per client; it opens with the first and closes with the last", async () => { + const { client, stream } = changesClient(); + const log: string[] = []; + const consumer = (name: string) => { + const controller = new AbortController(); + const done = watchServerChanges(client, { + signal: controller.signal, + onOpen: () => log.push(`${name}:open`), + onEvent: (event) => log.push(`${name}:${event.type}`), + onLost: () => log.push(`${name}:lost`), + }); + return { controller, done }; + }; + const a = consumer("a"); + await settled(); + expect(stream.attempts).toHaveLength(1); + // A late joiner reads its snapshot immediately: the subscription is already registered. + const b = consumer("b"); + await settled(); + expect(stream.attempts).toHaveLength(1); + stream.attempts[0].emit({ type: "policy" }); + await settled(); + expect(log).toEqual(["a:open", "b:open", "a:policy", "b:policy"]); + a.controller.abort(); + await a.done; + stream.attempts[0].emit({ type: "config" }); + await settled(); + expect(stream.attempts[0].signal.aborted).toBe(false); + expect(log.slice(-1)).toEqual(["b:config"]); + stream.attempts[0].end(); + await settled(); + expect(log.slice(-1)).toEqual(["b:lost"]); + b.controller.abort(); + await b.done; + expect(stream.attempts[0].signal.aborted).toBe(true); + // A new consumer after everyone left starts a fresh stream rather than joining a dead one. + const c = consumer("c"); + await settled(); + expect(stream.attempts).toHaveLength(2); + c.controller.abort(); + await c.done; +}); + +test("a rejected credential on the shared stream rejects every attached consumer once", async () => { + const { client, stream } = changesClient(); + const controllers = [new AbortController(), new AbortController()]; + const outcomes = controllers.map((controller) => + watchServerChanges(client, { signal: controller.signal, onEvent: () => {} }).then( + () => "resolved", + (cause: unknown) => (cause instanceof ORPCError ? cause.code : "other") + ) + ); + await settled(); + stream.attempts[0].fail(new ORPCError("UNAUTHORIZED")); + expect(await Promise.all(outcomes)).toEqual(["UNAUTHORIZED", "UNAUTHORIZED"]); + const retry = new AbortController(); + const again = watchServerChanges(client, { signal: retry.signal, onEvent: () => {} }); + await settled(); + expect(stream.attempts).toHaveLength(2); + retry.abort(); + await again; +}); diff --git a/packages/mobile/src/streams.ts b/packages/mobile/src/streams.ts index 613b2100495..cba1eb105c9 100644 --- a/packages/mobile/src/streams.ts +++ b/packages/mobile/src/streams.ts @@ -1,5 +1,6 @@ import { useSyncExternalStore } from "react"; -import { isAuthenticationError } from "./api"; +import type { ServerChangeEvent } from "../../../src/common/orpc/schemas/api"; +import { isAuthenticationError, type MobileClient } from "./api"; import { linkedAbortController } from "./useConnection"; // Each HTTP subscription is an independent long-lived response, so each one heals @@ -53,25 +54,6 @@ function sleep(ms: number, signal: AbortSignal): Promise { }); } -/** - * Interleave several subscriptions into one. When any of them ends or fails the merged - * stream does too, so a watch over the group reopens them together and re-reads the - * snapshot they jointly guard exactly once. The survivors are not closed here: a - * generator blocked in `next()` cannot be returned, so the sources must share the - * attempt signal that `watch` aborts after every attempt. - */ -export async function* merge(sources: AsyncIterable[]): AsyncGenerator { - const iterators = sources.map((source) => source[Symbol.asyncIterator]()); - const advance = (index: number) => iterators[index].next().then((result) => ({ index, result })); - const pending = iterators.map((_, index) => advance(index)); - while (true) { - const { index, result } = await Promise.race(pending); - if (result.done) return; - yield result.value; - pending[index] = advance(index); - } -} - export interface WatchOptions { signal: AbortSignal; /** Open one attempt. `retries` counts consecutive reopen attempts since the last stable stream. */ @@ -124,3 +106,83 @@ export async function watch(options: WatchOptions): Promise { setReconnecting(stream, false); } } + +interface ChangeConsumer { + signal: AbortSignal; + onOpen?: () => void; + onEvent: (event: ServerChangeEvent) => void; + onLost?: () => void; +} +interface SharedChanges { + consumers: Set; + controller: AbortController; + open: boolean; + settled: Promise; +} +const sharedChanges = new WeakMap(); + +/** + * Config, provider, policy and workspace-metadata changes all arrive on one + * server stream, shared by every consumer of the same client while any is mounted. + * Browsers and mobile URLSession cap HTTP/1.1 connections per host at about six, so + * with the conversation stream this leaves the unary calls that read the changed + * snapshots room to run. Resolves when `signal` aborts; rejects only when the + * credential is rejected. + */ +export function watchServerChanges( + client: Pick, + consumer: ChangeConsumer +): Promise { + if (consumer.signal.aborted) return Promise.resolve(); + let shared = sharedChanges.get(client); + if (!shared) { + const controller = new AbortController(); + const created: SharedChanges = { + consumers: new Set(), + controller, + open: false, + settled: watch({ + signal: controller.signal, + open: (attempt) => client.server.onChanged(undefined, { signal: attempt.signal }), + onOpen: () => { + created.open = true; + for (const each of [...created.consumers]) each.onOpen?.(); + }, + onEvent: (event) => { + for (const each of [...created.consumers]) each.onEvent(event); + }, + onLost: () => { + created.open = false; + for (const each of [...created.consumers]) each.onLost?.(); + }, + }).finally(() => { + if (sharedChanges.get(client) === created) sharedChanges.delete(client); + }), + }; + shared = created; + sharedChanges.set(client, shared); + } + const owner = shared; + owner.consumers.add(consumer); + // Joining an already registered subscription: the snapshot can be read right away. + if (owner.open) consumer.onOpen?.(); + return new Promise((resolve, reject) => { + const leave = () => { + owner.consumers.delete(consumer); + if (owner.consumers.size === 0) { + // Release synchronously: a remounting consumer must start a fresh stream, not + // join this aborted one before its watch has settled. + if (sharedChanges.get(client) === owner) sharedChanges.delete(client); + owner.controller.abort(); + } + resolve(); + }; + consumer.signal.addEventListener("abort", leave, { once: true }); + owner.settled.catch((cause: unknown) => { + if (!owner.consumers.has(consumer)) return; + consumer.signal.removeEventListener("abort", leave); + owner.consumers.delete(consumer); + reject(cause); + }); + }); +} diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts index 3a36ab81e8e..28848aad46e 100644 --- a/packages/mobile/src/useConversation.test.ts +++ b/packages/mobile/src/useConversation.test.ts @@ -44,38 +44,41 @@ function fixture( }); let eventController!: ReadableStreamDefaultController; const policyRequests: AbortSignal[] = []; - const policySubscriptions: Array<{ + type ChangeEvent = + Awaited> extends AsyncIterable + ? Event + : never; + type Notifier = { signal: AbortSignal; - events: ReadableStreamDefaultController; + events: { enqueue: () => void }; fail: (error: Error) => void; - }> = []; - const configSubscriptions: typeof policySubscriptions = []; - const providerSubscriptions: typeof policySubscriptions = []; + }; + // One shared change stream serves policy, config and provider notifications; the + // per-kind views below let each test speak about the kind it cares about. + const policySubscriptions: Notifier[] = []; + const configSubscriptions: Notifier[] = []; + const providerSubscriptions: Notifier[] = []; const settingsRequests: Array<{ path: string; signal: AbortSignal }> = []; const settingsOrder: string[] = []; - function notifications( - subscriptions: typeof policySubscriptions, - signal: AbortSignal, - source: string - ) { - const events = new ReadableStream({ + function changes(signal: AbortSignal) { + settingsOrder.push("changes.subscribe"); + return new ReadableStream({ start(controller) { const close = () => controller.close(); - subscriptions.push({ - signal, - events: controller, - fail(error) { - signal.removeEventListener("abort", close); - controller.error(error); - }, - }); + const fail = (error: Error) => { + signal.removeEventListener("abort", close); + controller.error(error); + }; + for (const [list, type] of [ + [policySubscriptions, "policy"], + [configSubscriptions, "config"], + [providerSubscriptions, "providers"], + ] as const) { + list.push({ signal, events: { enqueue: () => controller.enqueue({ type }) }, fail }); + } signal.addEventListener("abort", close, { once: true }); }, - }); - return (async function* () { - settingsOrder.push(`${source}.listen`); - yield* events.values(); - })(); + }).values(); } const restored: RestoredInput[] = []; const chatRequests: AbortSignal[] = []; @@ -87,27 +90,8 @@ function fixture( case "policy.get": policyRequests.push(options.signal!); return getPolicy(); - case "policy.onChanged": - return new ReadableStream({ - start(controller) { - const close = () => controller.close(); - policySubscriptions.push({ - signal: options.signal!, - events: controller, - fail(error) { - options.signal?.removeEventListener("abort", close); - controller.error(error); - }, - }); - options.signal?.addEventListener("abort", close, { once: true }); - }, - }).values(); - case "config.onConfigChanged": - settingsOrder.push("config.subscribe"); - return notifications(configSubscriptions, options.signal!, "config"); - case "providers.onConfigChanged": - settingsOrder.push("providers.subscribe"); - return notifications(providerSubscriptions, options.signal!, "providers"); + case "server.onChanged": + return changes(options.signal!); case "config.getConfig": settingsOrder.push("config.read"); settingsRequests.push({ path: "config", signal: options.signal! }); @@ -592,8 +576,10 @@ test("a failed policy read stays unavailable without hiding settings and a chang fail = false; await act(async () => view.policySubscriptions[0].events.enqueue()); await waitFor(() => expect(view.result.current.settings?.policy).toEqual(disabledPolicy)); + // Losing the shared change stream withdraws every snapshot it guards until it reopens. await act(async () => view.policySubscriptions[0].fail(new Error("subscription lost"))); - await waitFor(() => expect(view.result.current.settings?.policy).toBeNull()); + await waitFor(() => expect(view.result.current.settings).toBeNull()); + expect(view.result.current.settingsError).not.toBeNull(); }); test("a late policy response cannot update an aborted connection lifetime", async () => { @@ -648,9 +634,9 @@ test("settings subscriptions precede reads and refresh privacy, routes and provi await view.ready(); expect(view.configSubscriptions).toHaveLength(1); expect(view.providerSubscriptions).toHaveLength(1); - // Both subscriptions are registered before any settings snapshot is read. - expect(view.settingsOrder.slice(0, 2)).toEqual(["config.subscribe", "providers.subscribe"]); - expect(view.settingsOrder.slice(2)).toEqual( + // The change stream is registered before any settings snapshot is read. + expect(view.settingsOrder[0]).toBe("changes.subscribe"); + expect(view.settingsOrder.slice(1)).toEqual( expect.arrayContaining(["config.read", "agents.read", "providers.read"]) ); config = { diff --git a/packages/mobile/src/useConversation.ts b/packages/mobile/src/useConversation.ts index 93fe89030d6..a59565dbf11 100644 --- a/packages/mobile/src/useConversation.ts +++ b/packages/mobile/src/useConversation.ts @@ -6,7 +6,7 @@ import { resumeTranscriptState, type WorkspaceChatMessage, } from "./transcript"; -import { merge, watch } from "./streams"; +import { watch, watchServerChanges } from "./streams"; import { MOBILE_STREAM_DISPLAY_BATCH_MS, MOBILE_STREAM_MAX_PENDING_DELTAS, @@ -95,24 +95,10 @@ export function useConversation( ) .finally(() => request.abort()); } - watch({ - signal: controller.signal, - // Subscribe before the initial read so changes during that read are not lost. - open: (attempt) => client.policy.onChanged(undefined, { signal: attempt.signal }), - onOpen: refreshPolicy, - onEvent: refreshPolicy, - onLost: () => { - policyRequest?.abort(); - setPolicy(null); - }, - }).catch(() => { - if (!controller.signal.aborted) setPolicy(null); - }); - const settingsController = linkedAbortController(controller.signal); let settingsRequest: AbortController | null = null; function refreshSettings() { settingsRequest?.abort(); - const request = linkedAbortController(settingsController.signal); + const request = linkedAbortController(controller.signal); settingsRequest = request; // A notification invalidates the old privacy options immediately. Consume // further notifications while reading, so an older snapshot cannot win. @@ -139,23 +125,26 @@ export function useConversation( setSettings(null); setSettingsError("Settings unavailable. Retry to reconnect."); } - // Both subscriptions are registered before the snapshot they guard is read; a - // reopened pair re-reads because changes may have happened while it was down. - watch({ - signal: settingsController.signal, - open: async (attempt) => - merge( - await Promise.all([ - client.config.onConfigChanged(undefined, { signal: attempt.signal }), - client.providers.onConfigChanged(undefined, { signal: attempt.signal }), - ]) - ), - onOpen: refreshSettings, - onEvent: refreshSettings, - onLost: settingsUnavailable, + // The change stream is registered before any snapshot is read, and a reopened one + // re-reads everything because changes may have happened while it was down. + watchServerChanges(client, { + signal: controller.signal, + onOpen: () => { + refreshPolicy(); + refreshSettings(); + }, + onEvent: (event) => { + if (event.type === "policy") refreshPolicy(); + else if (event.type === "config" || event.type === "providers") refreshSettings(); + }, + onLost: () => { + policyRequest?.abort(); + setPolicy(null); + settingsUnavailable(); + }, }).catch(() => { if (controller.signal.aborted) return; - settingsController.abort(); + setPolicy(null); settingsUnavailable(); }); type Anchor = NonNullable< diff --git a/packages/mobile/src/useProjects.test.ts b/packages/mobile/src/useProjects.test.ts index d6a53006c03..4bf5aaede8e 100644 --- a/packages/mobile/src/useProjects.test.ts +++ b/packages/mobile/src/useProjects.test.ts @@ -10,8 +10,8 @@ import { wakeStreams } from "./streams"; afterEach(cleanup); -type MetadataEvent = - Awaited> extends AsyncIterable +type ChangeEvent = + Awaited> extends AsyncIterable ? Event : never; type Subscription = { @@ -38,8 +38,7 @@ function server() { const order: string[] = []; const projectReads: Array>> = []; const workspaceReads: Array>> = []; - const config: Array> = []; - const metadata: Array> = []; + const changes: Array> = []; function subscribe(subscriptions: Array>, signal: AbortSignal) { return new ReadableStream({ start(controller) { @@ -69,10 +68,8 @@ function server() { const method = path.join("."); order.push(method); switch (method) { - case "workspace.onMetadata": - return subscribe(metadata, options.signal); - case "config.onConfigChanged": - return subscribe(config, options.signal); + case "server.onChanged": + return subscribe(changes, options.signal); case "projects.list": { const next = request(options.signal); projectReads.push(next); @@ -88,7 +85,7 @@ function server() { } }, }); - return { client, order, projectReads, workspaceReads, config, metadata }; + return { client, order, projectReads, workspaceReads, changes }; } function mount(source = server()) { const lifetime = new AbortController(); @@ -114,7 +111,7 @@ test("subscribes before snapshots and refreshes the catalog without reconnect or const view = mount(); await view.ready(); const { source } = view; - expect(source.order.slice(0, 2)).toEqual(["workspace.onMetadata", "config.onConfigChanged"]); + expect(source.order[0]).toBe("server.onChanged"); const snapshots: Projects[] = [ [ ...catalog("renamed"), @@ -130,15 +127,14 @@ test("subscribes before snapshots and refreshes the catalog without reconnect or [], ]; for (const [index, next] of snapshots.entries()) { - await act(async () => source.config[0].emit()); + await act(async () => source.changes[0].emit({ type: "config" })); await waitFor(() => expect(source.projectReads).toHaveLength(index + 2)); await act(async () => source.projectReads[index + 1].resolve(next)); expect(view.result.current.projects).toEqual( next.filter(([path]) => path !== SCRATCH_PROJECT_CONFIG_KEY) ); } - expect(source.config).toHaveLength(1); - expect(source.metadata).toHaveLength(1); + expect(source.changes).toHaveLength(1); expect(source.workspaceReads).toHaveLength(1); expect(view.result.current.workspaces).toEqual([workspace]); }); @@ -148,21 +144,25 @@ test("new notifications cancel stale reads while workspace events remain live", const { source } = view; await waitFor(() => expect(source.projectReads).toHaveLength(1)); await act(async () => source.workspaceReads[0].resolve([workspace])); - expect(source.config).toHaveLength(1); - await act(async () => source.config[0].emit()); + expect(source.changes).toHaveLength(1); + await act(async () => source.changes[0].emit({ type: "config" })); await waitFor(() => expect(source.projectReads).toHaveLength(2)); expect(source.projectReads[0].signal.aborted).toBe(true); await act(async () => - source.metadata[0].emit({ workspaceId: "w", metadata: { ...workspace, title: "live update" } }) + source.changes[0].emit({ + type: "metadata", + workspaceId: "w", + metadata: { ...workspace, title: "live update" }, + }) ); expect(view.result.current.workspaces[0].title).toBe("live update"); await act(async () => source.projectReads[1].resolve(catalog("new"))); expect(view.result.current.loading).toBe(false); await act(async () => source.projectReads[0].resolve(catalog("stale"))); expect(view.result.current.projects).toEqual(catalog("new")); - await act(async () => source.config[0].emit()); + await act(async () => source.changes[0].emit({ type: "config" })); await waitFor(() => expect(source.projectReads).toHaveLength(3)); - await act(async () => source.config[0].emit()); + await act(async () => source.changes[0].emit({ type: "config" })); await waitFor(() => expect(source.projectReads).toHaveLength(4)); await act(async () => source.projectReads[3].resolve(catalog("latest"))); await act(async () => source.projectReads[2].reject(new Error("stale error"))); @@ -176,14 +176,20 @@ test("metadata arriving during the initial workspace read is applied after its s const { source } = view; await waitFor(() => expect(source.projectReads).toHaveLength(1)); await act(async () => - source.metadata[0].emit({ workspaceId: "w", metadata: { ...workspace, title: "new title" } }) + source.changes[0].emit({ + type: "metadata", + workspaceId: "w", + metadata: { ...workspace, title: "new title" }, + }) ); await act(async () => { source.projectReads[0].resolve(catalog("project")); source.workspaceReads[0].resolve([workspace]); }); expect(view.result.current.workspaces[0].title).toBe("new title"); - await act(async () => source.metadata[0].emit({ workspaceId: "w", metadata: null })); + await act(async () => + source.changes[0].emit({ type: "metadata", workspaceId: "w", metadata: null }) + ); expect(view.result.current.workspaces).toEqual([]); }); @@ -203,11 +209,11 @@ test("replacement connections, retry generations and cancellation cannot apply o }); expect(view.result.current.projects).toEqual(catalog("replacement")); expect(view.result.current.workspaces).toEqual([workspace]); - await act(async () => next.config[0].emit()); + await act(async () => next.changes[0].emit({ type: "config" })); await waitFor(() => expect(next.projectReads).toHaveLength(2)); act(() => view.result.current.retry()); await waitFor(() => expect(next.projectReads).toHaveLength(3)); - expect(next.config[0].signal.aborted).toBe(true); + expect(next.changes[0].signal.aborted).toBe(true); expect(next.projectReads[1].signal.aborted).toBe(true); await act(async () => { next.projectReads[2].resolve(catalog("retried")); @@ -215,7 +221,7 @@ test("replacement connections, retry generations and cancellation cannot apply o next.projectReads[1].resolve(catalog("old generation")); }); expect(view.result.current.projects).toEqual(catalog("retried")); - await act(async () => next.config[1].emit()); + await act(async () => next.changes[1].emit({ type: "config" })); await waitFor(() => expect(next.projectReads).toHaveLength(4)); act(() => view.lifetime.abort()); expect(next.projectReads[3].signal.aborted).toBe(true); @@ -223,40 +229,29 @@ test("replacement connections, retry generations and cancellation cannot apply o expect(view.result.current.projects).toEqual(catalog("retried")); }); -test.each([ - { kind: "config", ending: "end" }, - { kind: "config", ending: "fail" }, - { kind: "metadata", ending: "end" }, - { kind: "metadata", ending: "fail" }, -] as const)( - "a $kind subscription $ending keeps the catalog, reopens on its own and re-reads its snapshot", - async ({ kind, ending }) => { +test.each(["end", "fail"] as const)( + "a change-stream %s keeps both catalogs, reopens on its own and re-reads both snapshots", + async (ending) => { const view = mount(); await view.ready(); const { source } = view; - const other = kind === "config" ? "metadata" : "config"; - await act(async () => source[kind][0][ending]()); + await act(async () => source.changes[0][ending]()); expect(view.result.current.error).toBeNull(); expect(view.result.current.projects).toEqual(catalog("initial")); - expect(source[other][0].signal.aborted).toBe(false); + expect(view.result.current.workspaces).toEqual([workspace]); act(() => wakeStreams()); - await waitFor(() => expect(source[kind]).toHaveLength(2)); - // The reopened subscription is registered before its guarded snapshot is re-read. - const reads = kind === "config" ? source.projectReads : source.workspaceReads; - await waitFor(() => expect(reads).toHaveLength(2)); - expect(source.order.slice(-2)).toEqual([ - kind === "config" ? "config.onConfigChanged" : "workspace.onMetadata", - kind === "config" ? "projects.list" : "workspace.list", - ]); + await waitFor(() => expect(source.changes).toHaveLength(2)); + // The reopened stream is registered before the guarded snapshots are re-read. + await waitFor(() => expect(source.projectReads).toHaveLength(2)); + expect(source.workspaceReads).toHaveLength(2); + expect(source.order.slice(-3)).toEqual(["server.onChanged", "workspace.list", "projects.list"]); await act(async () => { - if (kind === "config") source.projectReads[1].resolve(catalog("healed")); - else source.workspaceReads[1].resolve([{ ...workspace, name: "healed" }]); + source.projectReads[1].resolve(catalog("healed")); + source.workspaceReads[1].resolve([{ ...workspace, name: "healed" }]); }); expect(view.result.current.error).toBeNull(); - expect( - kind === "config" ? view.result.current.projects : view.result.current.workspaces - ).toEqual(kind === "config" ? catalog("healed") : [{ ...workspace, name: "healed" }]); - expect(source[other]).toHaveLength(1); + expect(view.result.current.projects).toEqual(catalog("healed")); + expect(view.result.current.workspaces).toEqual([{ ...workspace, name: "healed" }]); } ); @@ -264,18 +259,19 @@ test("a failed refresh exposes retry without losing the catalog, and the next re const view = mount(); await view.ready(); const { source } = view; - expect(source.config).toHaveLength(1); - await act(async () => source.config[0].emit()); + expect(source.changes).toHaveLength(1); + await act(async () => source.changes[0].emit({ type: "config" })); await waitFor(() => expect(source.projectReads).toHaveLength(2)); await act(async () => source.projectReads[1].reject(new Error("refresh failed"))); expect(view.result.current.error).toBe("refresh failed"); expect(view.result.current.projects).toEqual(catalog("initial")); - // Subscriptions stay live: a later invalidation can heal the catalog without Retry. - expect(source.config[0].signal.aborted).toBe(false); - expect(source.metadata[0].signal.aborted).toBe(false); - await act(async () => source.metadata[0].emit({ workspaceId: "w", metadata: null })); + // The stream stays live: a later invalidation can heal the catalog without Retry. + expect(source.changes[0].signal.aborted).toBe(false); + await act(async () => + source.changes[0].emit({ type: "metadata", workspaceId: "w", metadata: null }) + ); expect(view.result.current.workspaces).toEqual([]); - await act(async () => source.config[0].emit()); + await act(async () => source.changes[0].emit({ type: "config" })); await waitFor(() => expect(source.projectReads).toHaveLength(3)); await act(async () => source.projectReads[2].resolve(catalog("healed"))); expect(view.result.current.error).toBeNull(); @@ -309,11 +305,15 @@ test("metadata held during a failed snapshot read is applied to the retained lis const view = mount(); await view.ready(); const { source } = view; - await act(async () => source.metadata[0].end()); + await act(async () => source.changes[0].end()); act(() => wakeStreams()); await waitFor(() => expect(source.workspaceReads).toHaveLength(2)); await act(async () => - source.metadata[1].emit({ workspaceId: "w", metadata: { ...workspace, title: "during read" } }) + source.changes[1].emit({ + type: "metadata", + workspaceId: "w", + metadata: { ...workspace, title: "during read" }, + }) ); expect(view.result.current.workspaces[0].title).toBeUndefined(); await act(async () => source.workspaceReads[1].reject(new Error("relist failed"))); diff --git a/packages/mobile/src/useProjects.ts b/packages/mobile/src/useProjects.ts index da367b54f4a..d5c74f155dd 100644 --- a/packages/mobile/src/useProjects.ts +++ b/packages/mobile/src/useProjects.ts @@ -1,16 +1,14 @@ import { useEffect, useState } from "react"; import type { MobileClient } from "./api"; import type { FrontendWorkspaceMetadata } from "../../../src/common/types/workspace"; +import type { ServerChangeEvent } from "../../../src/common/orpc/schemas/api"; import { isWorkspaceArchived } from "../../../src/common/utils/archive"; import { SCRATCH_PROJECT_CONFIG_KEY } from "../../../src/common/constants/scratch"; import { linkedAbortController } from "./useConnection"; -import { watch } from "./streams"; +import { watchServerChanges } from "./streams"; export type Projects = Awaited>; -type MetadataEvent = - Awaited> extends AsyncIterable - ? Event - : never; +type MetadataEvent = Extract; export function useProjects(client: MobileClient, signal: AbortSignal) { const [projects, setProjects] = useState([]); @@ -103,27 +101,24 @@ export function useProjects(client: MobileClient, signal: AbortSignal) { }, releaseMetadata ); - // Each subscription is registered before the snapshot it guards is read, so - // changes during the read cannot be missed; a reopened one re-reads because + // The change stream is registered before either snapshot is read, so changes + // during a read cannot be missed; a reopened stream re-reads both because // changes may have happened while it was down. - Promise.all([ - watch({ - signal: controller.signal, - open: (attempt) => client.workspace.onMetadata(undefined, { signal: attempt.signal }), - onOpen: () => { - heldMetadata = []; - refreshWorkspaces(); - }, - onEvent: (event) => (heldMetadata ? heldMetadata.push(event) : applyMetadata(event)), - }), - watch({ - signal: controller.signal, - open: (attempt) => client.config.onConfigChanged(undefined, { signal: attempt.signal }), - onOpen: refreshProjects, - onEvent: refreshProjects, - }), - ]).catch(() => { - // Only a rejected credential ends the watches; everything else retries. + watchServerChanges(client, { + signal: controller.signal, + onOpen: () => { + heldMetadata = []; + refreshWorkspaces(); + refreshProjects(); + }, + onEvent: (event) => { + if (event.type === "config") refreshProjects(); + else if (event.type === "metadata") + if (heldMetadata) heldMetadata.push(event); + else applyMetadata(event); + }, + }).catch(() => { + // Only a rejected credential ends the stream; everything else retries. if (controller.signal.aborted) return; setError("The server rejected this session. Retry to reconnect or sign in again."); setLoading(false); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 129c8df22b3..37285dee902 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2472,6 +2472,18 @@ export const ServerAuthSessionSchema = z.object({ isCurrent: z.boolean(), }); +export const ServerChangeEventSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("config") }), + z.object({ type: z.literal("providers") }), + z.object({ type: z.literal("policy") }), + z.object({ + type: z.literal("metadata"), + workspaceId: z.string(), + metadata: FrontendWorkspaceMetadataSchema.nullable(), + }), +]); +export type ServerChangeEvent = z.infer; + export const server = { getLaunchProject: { input: z.void(), @@ -2497,6 +2509,17 @@ export const server = { }), output: ApiServerStatusSchema, }, + /** + * Subscription: every control-plane change a thin client tracks, on one stream. + * HTTP clients hold one long-lived response per subscription, and browsers and + * mobile URLSession cap HTTP/1.1 connections per host at about six, so the + * separate config/providers/policy/metadata subscriptions would starve the + * unary calls that must read the changed snapshots. + */ + onChanged: { + input: z.void(), + output: eventIterator(ServerChangeEventSchema), + }, }; export const serverAuth = { diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 3e7ceac7336..c9e80d13c46 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -102,6 +102,7 @@ import { subscribeBackgroundBashes, subscribeMemoryChanges, subscribeMetadata, + subscribeServerChanges, subscribeOpenSettings, subscribePolicyChanges, subscribeDesignExperiment, @@ -236,6 +237,10 @@ export const router = (authToken?: string) => { .input(schemas.server.getLaunchProject.input) .output(schemas.server.getLaunchProject.output) .handler(async ({ context }) => context.serverService.getLaunchProject()), + onChanged: t + .input(schemas.server.onChanged.input) + .output(schemas.server.onChanged.output) + .handler(({ context, signal }) => subscribeServerChanges(context, signal)), getSshHost: t .input(schemas.server.getSshHost.input) .output(schemas.server.getSshHost.output) diff --git a/src/node/orpc/routerSubscriptions.test.ts b/src/node/orpc/routerSubscriptions.test.ts index 2e454232851..d5bcf567a9a 100644 --- a/src/node/orpc/routerSubscriptions.test.ts +++ b/src/node/orpc/routerSubscriptions.test.ts @@ -13,7 +13,11 @@ import { TestClock } from "effect/testing"; import { SUBSCRIPTION_HEARTBEAT_INTERVAL_MS } from "@/common/utils/withQueueHeartbeat"; import { disposeAppRuntime, makeAppRuntime } from "@/node/services/di/appRuntime"; import type { ORPCContext } from "./context"; -import { subscribeWorkspaceActivity, subscribeDesignExperiment } from "./routerSubscriptions"; +import { + subscribeDesignExperiment, + subscribeServerChanges, + subscribeWorkspaceActivity, +} from "./routerSubscriptions"; test("subscription handlers forward the oRPC runtime Clock", async () => { const app = makeAppRuntime(TestClock.layer()); @@ -96,3 +100,46 @@ test("Design subscriptions publish sibling changes only after client shutdown", await stream.return(undefined); } }); + +test("server change subscription fans in every control-plane source and releases them on abort", async () => { + const app = makeAppRuntime(TestClock.layer()); + const workspaceService = new EventEmitter(); + const listeners = { + config: new Set<() => void>(), + providers: new Set<() => void>(), + policy: new Set<() => void>(), + }; + const source = (set: Set<() => void>) => (callback: () => void) => { + set.add(callback); + return () => set.delete(callback); + }; + const context = { + "effect/context": app.context, + workspaceService, + config: { onConfigChanged: source(listeners.config) }, + providerService: { onConfigChanged: source(listeners.providers) }, + policyService: { onPolicyChanged: source(listeners.policy) }, + } as unknown as ORPCContext; + const controller = new AbortController(); + const events: unknown[] = []; + const stream = subscribeServerChanges(context, controller.signal); + const first = stream.next(); + for (const set of Object.values(listeners)) expect(set.size).toBe(1); + listeners.policy.forEach((emit) => emit()); + listeners.config.forEach((emit) => emit()); + workspaceService.emit("metadata", { workspaceId: "w", metadata: null }); + listeners.providers.forEach((emit) => emit()); + events.push((await first).value); + for (let i = 0; i < 3; i++) events.push((await stream.next()).value); + expect(events).toEqual([ + { type: "policy" }, + { type: "config" }, + { type: "metadata", workspaceId: "w", metadata: null }, + { type: "providers" }, + ]); + controller.abort(); + await stream.next().catch(() => undefined); + await disposeAppRuntime(app.managed); + for (const set of Object.values(listeners)) expect(set.size).toBe(0); + expect(workspaceService.listenerCount("metadata")).toBe(0); +}); diff --git a/src/node/orpc/routerSubscriptions.ts b/src/node/orpc/routerSubscriptions.ts index 8c0a374f894..9cf3a516f7b 100644 --- a/src/node/orpc/routerSubscriptions.ts +++ b/src/node/orpc/routerSubscriptions.ts @@ -11,6 +11,7 @@ import type { MemoryChangeEventPayload, MemoryConsolidationStatusChangeEventPayload, } from "@/common/orpc/schemas/memory"; +import type { ServerChangeEvent } from "@/common/orpc/schemas/api"; import type { SshPromptEvent, SshPromptRequest } from "@/common/orpc/schemas/ssh"; import type { TimelineSubscriptionEvent } from "@/common/orpc/schemas/timeline"; import type { DevToolsEvent } from "@/common/types/devtools"; @@ -307,6 +308,29 @@ export function subscribeWorkspaceChat( }); } +/** Fan-in of the change sources a thin HTTP client would otherwise stream separately. */ +export function subscribeServerChanges( + context: ORPCContext, + signal?: AbortSignal +): AsyncGenerator { + return runtimeSubscription(context, { + signal, + subscribe: (emit) => { + const onMetadata = (event: MetadataEvent) => emit.push({ type: "metadata", ...event }); + context.workspaceService.on("metadata", onMetadata); + const unsubscribe = [ + context.config.onConfigChanged(() => emit.push({ type: "config" })), + context.providerService.onConfigChanged(() => emit.push({ type: "providers" })), + context.policyService.onPolicyChanged(() => emit.push({ type: "policy" })), + () => context.workspaceService.off("metadata", onMetadata), + ]; + return () => { + for (const dispose of unsubscribe) dispose(); + }; + }, + }); +} + export function subscribeMetadata( context: ORPCContext, signal?: AbortSignal diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index dd1bc27d62d..a42b53802fd 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -7091,7 +7091,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "The token grants access to the server, including its code-execution capabilities. Treat it like a password. Native builds save connection details in device secure storage. The web preview keeps them in memory only; refreshing requires entering them again. Disconnect clears the saved native connection.", "", - "The companion talks to the server over plain HTTP oRPC: every call is its own request carrying the token in an `Authorization` header, and each live subscription (conversation events, workspace metadata, config/provider/policy changes) is its own streamed response. There is no shared socket or per-connection state, so a cellular handoff or a backgrounded app only interrupts the streams that were open; each one reconnects on its own with capped backoff, and a dropped conversation resumes from the server's cursor rather than replaying the whole transcript. Unary calls and mutations are never retried automatically. The token never appears in a URL.", + "The companion talks to the server over plain HTTP oRPC: every call is its own request carrying the token in an `Authorization` header, and live updates arrive on streamed responses—one shared change stream for workspace metadata and config/provider/policy changes, plus one conversation stream per open conversation. Holding at most two streams matters because browsers and mobile HTTP stacks cap HTTP/1.1 connections per host at about six. There is no shared socket or per-connection state, so a cellular handoff or a backgrounded app only interrupts the streams that were open; each one reconnects on its own with capped backoff, and a dropped conversation resumes from the server's cursor rather than replaying the whole transcript. Unary calls and mutations are never retried automatically. The token never appears in a URL.", "", "All non-loopback endpoints—including private LAN, ULA, and link-local addresses—require HTTPS. HTTP is accepted only for `localhost`, IPv4 loopback (`127.0.0.0/8`), or IPv6 loopback (`[::1]`) for development, with a plaintext-token warning. A phone's `localhost` refers to the phone, not your development computer: use a trusted HTTPS endpoint to reach that computer from a device.", "", From 3d401679b20e4007dee1882aa32c867bb9d0c125 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 09:22:27 +0000 Subject: [PATCH 83/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20keep=20a=20?= =?UTF-8?q?self-healing=20outage=20free=20of=20Retry=20notices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dropped change stream withdraws the settings and policy snapshots so sending pauses, but it is not an error: the stream reconnects on its own and the Reconnecting indicator already explains the pause. --- _Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking: `high` • Cost: `$1716.99`_ --- packages/mobile/src/useConversation.test.ts | 9 ++++++--- packages/mobile/src/useConversation.ts | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts index 28848aad46e..09f42ae58a0 100644 --- a/packages/mobile/src/useConversation.test.ts +++ b/packages/mobile/src/useConversation.test.ts @@ -576,10 +576,13 @@ test("a failed policy read stays unavailable without hiding settings and a chang fail = false; await act(async () => view.policySubscriptions[0].events.enqueue()); await waitFor(() => expect(view.result.current.settings?.policy).toEqual(disabledPolicy)); - // Losing the shared change stream withdraws every snapshot it guards until it reopens. + // Losing the shared change stream withdraws every snapshot it guards until it + // reopens, without raising an error: the stream heals on its own. await act(async () => view.policySubscriptions[0].fail(new Error("subscription lost"))); await waitFor(() => expect(view.result.current.settings).toBeNull()); - expect(view.result.current.settingsError).not.toBeNull(); + expect(view.result.current.settingsError).toBeNull(); + act(() => wakeStreams()); + await waitFor(() => expect(view.result.current.settings?.policy).toEqual(disabledPolicy)); }); test("a late policy response cannot update an aborted connection lifetime", async () => { @@ -766,7 +769,7 @@ test.each(["config", "providers", "agents"] as const)( expect(view.result.current.settingsError).toBeNull(); await act(async () => subscription.fail(new Error("disconnected"))); await waitFor(() => expect(view.result.current.settings).toBeNull()); - expect(view.result.current.settingsError).not.toBeNull(); + expect(view.result.current.settingsError).toBeNull(); expect(view.result.current.error).toBeNull(); } ); diff --git a/packages/mobile/src/useConversation.ts b/packages/mobile/src/useConversation.ts index a59565dbf11..5a95ea57451 100644 --- a/packages/mobile/src/useConversation.ts +++ b/packages/mobile/src/useConversation.ts @@ -138,9 +138,12 @@ export function useConversation( else if (event.type === "config" || event.type === "providers") refreshSettings(); }, onLost: () => { + // Withdraw the snapshots (they may be stale) but raise no error: the stream + // heals itself, and "Reconnecting…" already says why sending is paused. policyRequest?.abort(); + settingsRequest?.abort(); setPolicy(null); - settingsUnavailable(); + setSettings(null); }, }).catch(() => { if (controller.signal.aborted) return; From a13d9deba986e827efb2743d3826ec463508e27d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 09:53:19 +0000 Subject: [PATCH 84/84] =?UTF-8?q?=F0=9F=A4=96=20fix(mobile):=20retire=20an?= =?UTF-8?q?=20open=20Changes=20screen=20when=20the=20checkout=20disappears?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route now follows live workspace metadata: a transcript-only or removed workspace replaces the diff view instead of refreshing Git into a missing checkout, and restoring the checkout brings it back in place. A stream wake issued while a stream was already dying now skips the first backoff instead of being lost. --- _Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking: `high` • Cost: `$1725.87`_ --- packages/mobile/App.tsx | 39 ++++++++++++++----- .../mobile/src/screens/session.behavior.tsx | 20 ++++++++++ packages/mobile/src/streams.ts | 8 +++- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx index 63406dcb04f..e6bcb757119 100644 --- a/packages/mobile/App.tsx +++ b/packages/mobile/App.tsx @@ -309,16 +309,37 @@ function ConversationRoute(props: NativeStackScreenProps) { - const { session } = useSession(); + const { session, data } = useSession(); + const { workspaceId } = props.route.params; + const workspace = data.workspaces.find((item) => item.id === workspaceId); + // Live metadata decides whether Git can still run here: a checkout deleted while + // this screen is open must retire it rather than keep refreshing into errors. + const available = workspace !== undefined && workspace.transcriptOnly !== true; return ( - - props.navigation.goBack()} - /> + + {available ? ( + props.navigation.goBack()} + /> + ) : ( + <> +
props.navigation.goBack()} /> + + + {workspace + ? "This workspace's worktree is no longer available, so there are no changes to show." + : "This workspace is no longer available."} + + + + + )} ); } diff --git a/packages/mobile/src/screens/session.behavior.tsx b/packages/mobile/src/screens/session.behavior.tsx index 4f147a565c6..09239acc35d 100644 --- a/packages/mobile/src/screens/session.behavior.tsx +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -159,6 +159,8 @@ function fixture( return send(); case "workspace.executeBash": return { success: true, data: { success: true, output: "" } }; + case "workspace.getProjectDiffs": + return []; default: throw new Error(`Unexpected call: ${name}`); } @@ -1824,3 +1826,21 @@ describe("web composer keyboard", () => { expect(callCount(view, "interruptStream")).toBe(1); }); }); + +test("an open Changes screen retires itself when live metadata marks the checkout gone", async () => { + const view = fixture(); + await view.select("alpha"); + fireEvent.click(view.getByRole("button", { name: "View changes" })); + await waitFor(() => expect(callCount(view, "getProjectDiffs")).toBe(1)); + expect(stackState.routes.at(-1)?.name).toBe("Changes"); + await view.updateWorkspace({ ...workspaces[0], transcriptOnly: true }); + expect(view.getByText(/so there are no changes to show/)).toBeDefined(); + expect(view.queryByRole("button", { name: "Refresh changes" })).toBeNull(); + expect(callCount(view, "getProjectDiffs")).toBe(1); + // Restoring the checkout brings the live view back without navigating. + await view.updateWorkspace(workspaces[0]); + await waitFor(() => expect(callCount(view, "getProjectDiffs")).toBe(2)); + expect(stackState.routes.at(-1)?.name).toBe("Changes"); + fireEvent.click(view.getByRole("button", { name: "Refresh changes" })); + await waitFor(() => expect(callCount(view, "getProjectDiffs")).toBe(3)); +}); diff --git a/packages/mobile/src/streams.ts b/packages/mobile/src/streams.ts index cba1eb105c9..59829d8ba6b 100644 --- a/packages/mobile/src/streams.ts +++ b/packages/mobile/src/streams.ts @@ -11,8 +11,10 @@ export const STREAM_RETRY_MIN_MS = 1_000; export const STREAM_RETRY_MAX_MS = 30_000; const wakers = new Set<() => void>(); +let wakeGeneration = 0; /** Skip pending backoff, e.g. when the app returns to the foreground. */ export function wakeStreams(): void { + wakeGeneration++; for (const wake of [...wakers]) wake(); } @@ -76,6 +78,7 @@ export async function watch(options: WatchOptions): Promise { try { while (!options.signal.aborted) { const attempt = linkedAbortController(options.signal); + const wakesBefore = wakeGeneration; let openedAt: number | null = null; let cause: unknown; try { @@ -100,7 +103,10 @@ export async function watch(options: WatchOptions): Promise { if (openedAt !== null && Date.now() - openedAt >= STREAM_RETRY_MAX_MS) retries = 0; setReconnecting(stream, true); options.onLost?.(cause); - await sleep(backoff(retries++), options.signal); + // A wake during this attempt (e.g. foregrounding while the stream was already + // dying) is not lost to a race with the loss detection: retry right away. + if (wakeGeneration === wakesBefore) await sleep(backoff(retries), options.signal); + retries++; } } finally { setReconnecting(stream, false);