diff --git a/.dockerignore b/.dockerignore index 80f1ab29bb0..96d80c86d4b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -55,3 +55,6 @@ Thumbs.db !docs/**/*.md !docs/**/*.mdx LICENSE + +# Native companion is built separately from the desktop server. +packages/mobile/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 381c6df8b7b..bee23be6afd 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/.gitignore b/.gitignore index 8fd60aec657..a12531a6533 100644 --- a/.gitignore +++ b/.gitignore @@ -138,7 +138,7 @@ docs/theme/pagetoc.js # Removed mobile/ Expo prototype: upgraded checkouts keep untracked leftovers # (node_modules, .expo, ios, android) that its deleted nested .gitignore used # to hide. Ignore the whole dead path so git status stays clean. -mobile/ +/mobile/ # Terminal-Bench leaderboard cache benchmarks/terminal_bench/.leaderboard_cache/ # Terminal-Bench job output diff --git a/Makefile b/Makefile index 23033945f09..327356ca3a1 100644 --- a/Makefile +++ b/Makefile @@ -369,6 +369,8 @@ build/icon.png: docs/img/logo-white.svg scripts/generate-icons.ts # Keep the default local path fast. Docs link crawling and lockfile-free bench-agent # verification stay in static-check-full so local validation remains responsive. static-check: lint typecheck fmt-check check-eager-imports check-code-docs-links lint-shellcheck lint-hadolint ## Run fast local static checks + @node packages/mobile/scripts/generateTheme.cjs --check + @bun test ./packages/mobile/scripts/generateTheme.test.ts static-check-full: static-check check-bench-agent check-docs-links ## Run the full CI static check suite @@ -393,14 +395,14 @@ lint-zizmor: ## Run zizmor security analysis on GitHub Actions workflows # Prune any node_modules tree (not just ./node_modules) plus the removed mobile/ # prototype: upgraded checkouts may keep stale untracked leftovers there (e.g. # CocoaPods .sh scripts under mobile/ios) that would otherwise be scanned. -SHELL_SRC_FILES := $(shell find . -not \( -path '*/.git/*' -o -path '*/node_modules/*' -o -path './mobile/*' -o -path './build/*' -o -path './dist/*' -o -path './release/*' -o -path './benchmarks/terminal_bench/.leaderboard_cache/*' \) -type f -name '*.sh' 2>/dev/null) +SHELL_SRC_FILES := $(shell find . -not \( -path '*/.git/*' -o -path '*/node_modules/*' -o -path './mobile/*' -o -path './packages/mobile/ios/*' -o -path './packages/mobile/android/*' -o -path './build/*' -o -path './dist/*' -o -path './release/*' -o -path './benchmarks/terminal_bench/.leaderboard_cache/*' \) -type f -name '*.sh' 2>/dev/null) lint-shellcheck: ## Run shellcheck on shell scripts @echo "Running shellcheck on $(words $(SHELL_SRC_FILES)) shell scripts..." @shellcheck --external-sources $(SHELL_SRC_FILES) # Dockerfiles to lint (excludes node_modules, build artifacts, .git) -DOCKERFILES := $(shell find . -not \( -path '*/.git/*' -o -path '*/node_modules/*' -o -path './mobile/*' -o -path './build/*' -o -path './dist/*' -o -path './release/*' -o -path './benchmarks/terminal_bench/.leaderboard_cache/*' \) -type f -name 'Dockerfile' 2>/dev/null) +DOCKERFILES := $(shell find . -not \( -path '*/.git/*' -o -path '*/node_modules/*' -o -path './mobile/*' -o -path './packages/mobile/ios/*' -o -path './packages/mobile/android/*' -o -path './build/*' -o -path './dist/*' -o -path './release/*' -o -path './benchmarks/terminal_bench/.leaderboard_cache/*' \) -type f -name 'Dockerfile' 2>/dev/null) lint-hadolint: ## Run hadolint on Dockerfiles @echo "Running hadolint on $(words $(DOCKERFILES)) Dockerfiles..." @@ -588,6 +590,59 @@ test-storybook: node_modules/.installed ## Run Storybook interaction tests (requ @# Storybook story transitions can exceed Jest's default 15s timeout on loaded CI runners. @bun x test-storybook --testTimeout 30000 +## 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-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 + @cd packages/mobile && bun install --frozen-lockfile + @touch $@ + +# Node owns the preview's WebSocket upgrades; Bun's node:http compatibility can +# accept the upgrade but stall forwarding oRPC frames. +packages/mobile/.expo/preview.mjs: packages/mobile/scripts/preview.ts packages/mobile/src/endpoint.ts packages/mobile/node_modules/.installed + @mkdir -p packages/mobile/.expo + @bun build packages/mobile/scripts/preview.ts --target=node --format=esm --packages=external --outfile=$@ + +mobile-web: mobile-install packages/mobile/.expo/preview.mjs ## Run RN Web + fixed-target proxy (set XUM_MOBILE_ENDPOINT) + @test -n "$$XUM_MOBILE_ENDPOINT" || (echo 'Set XUM_MOBILE_ENDPOINT to your Xum server URL'; exit 1) + @XUM_MOBILE_METRO=http://127.0.0.1:$(MOBILE_METRO_PORT) bun x concurrently -k \ + "cd packages/mobile && BROWSER=none bun x expo start --web --port $(MOBILE_METRO_PORT)" \ + "node packages/mobile/.expo/preview.mjs" + +mobile-native: mobile-install ## Start Expo for a native device or simulator + @cd packages/mobile && bun x expo start --port $(MOBILE_METRO_PORT) + +mobile-preview: mobile-install packages/mobile/.expo/preview.mjs ## Serve exported RN Web through the fixed-target proxy + @XUM_MOBILE_STATIC_DIR=$(CURDIR)/packages/mobile/dist node packages/mobile/.expo/preview.mjs + +mobile-export: mobile-install ## Export production React Native Web + @cd packages/mobile && bun x expo export --platform web + +mobile-export-ios: mobile-install ## Compile the iOS JS bundle (not a native simulator build) + @cd packages/mobile && bun x expo export --platform ios --output-dir dist-ios + +mobile-typecheck: mobile-install ## Typecheck the mobile app against shared Xum contracts + @cd packages/mobile && bun x tsc --noEmit + +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 + +mobile-fmt: ## Format mobile source and configuration + @cd packages/mobile && ../../node_modules/.bin/prettier --write '**/*.{ts,tsx,json,mjs,cjs}' + +mobile-check: mobile-typecheck mobile-test mobile-lint ## Run mobile validation independently of desktop dependencies + @node packages/mobile/scripts/generateTheme.cjs --check + @cd packages/mobile && ../../node_modules/.bin/prettier --check '**/*.{ts,tsx,json,mjs,cjs}' + ## Benchmarks benchmark-terminal: ## Run Terminal-Bench 2.0 with Harbor (use TB_HARBOR_PACKAGE/TB_HARBOR_DAYTONA_PACKAGE/TB_DATASET/TB_CONCURRENCY/TB_TIMEOUT/TB_ENV/TB_MODEL/TB_ARGS to customize) @# Pin Harbor with the Daytona extra so scheduled ingestion does not break on future CLI or adapter API drift. diff --git a/docs/docs.json b/docs/docs.json index a710db5c15d..0694accc317 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -113,7 +113,11 @@ }, { "group": "Integrations", - "pages": ["integrations/vscode-extension", "integrations/acp"] + "pages": [ + "integrations/vscode-extension", + "integrations/acp", + "integrations/mobile-app" + ] }, { "group": "Reference", diff --git a/docs/integrations/mobile-app.md b/docs/integrations/mobile-app.md new file mode 100644 index 00000000000..31c3bff787f --- /dev/null +++ b/docs/integrations/mobile-app.md @@ -0,0 +1,126 @@ +--- +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. 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 + +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. + +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. + +## 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` 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. + +With the Message composer focused, Enter sends in desktop-sized windows with a fine pointer; Shift+Enter inserts a newline. Narrow windows and coarse-pointer devices keep Enter for newlines. Ctrl+Enter (or Cmd+Enter on macOS) sends while idle on either layout. Escape uses the active conversation's Stop action. Shortcuts respect disabled actions and composition, do not run from other inputs or modal dialogs, and do not queue messages or stop a turn when Enter is pressed during streaming. + +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 runs under Node and forwards streamed subscription responses without buffering. + +## 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 +``` + +Send/Stop keyboard shortcuts currently apply only to React Native Web. Native software-keyboard Enter behavior is unchanged; native hardware-keyboard Send/Stop bindings are not implemented. The installed native TextInput APIs do not expose the modifier/source information needed for that behavior without additional native integration. + +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. + +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. +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 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? + +**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/) diff --git a/packages/mobile/.gitignore b/packages/mobile/.gitignore new file mode 100644 index 00000000000..800e53accbc --- /dev/null +++ b/packages/mobile/.gitignore @@ -0,0 +1,7 @@ +.expo/ +node_modules/ +dist/ +dist-ios/ +ios/ +android/ +*.tsbuildinfo diff --git a/packages/mobile/.prettierignore b/packages/mobile/.prettierignore new file mode 100644 index 00000000000..e2b0b366d55 --- /dev/null +++ b/packages/mobile/.prettierignore @@ -0,0 +1,6 @@ +.expo/ +dist/ +dist-ios/ +ios/ +android/ +node_modules/ diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx new file mode 100644 index 00000000000..e6bcb757119 --- /dev/null +++ b/packages/mobile/App.tsx @@ -0,0 +1,360 @@ +import { + createContext, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { ComponentProps, ReactNode } from "react"; +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"; +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"; +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, 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"; + +export type MobileRoutes = { + Workspaces: undefined; + Conversation: { workspaceId: string }; + Changes: { workspaceId: string }; + Settings: undefined; +}; +const Stack = createNativeStackNavigator(); + +type SessionContext = { + session: ReturnType; + data: ReturnType; + drafts: ReturnType; + selections: Record; + setSelection: (id: string, value: ChatSettings) => 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)} /> + ) : ( + + + + )} + + + ); +} + +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>({}); + const [onCreated, setOnCreated] = useState< + ((workspace: FrontendWorkspaceMetadata) => void) | null + >(null); + const [disconnectError, setDisconnectError] = useState(null); + const [disconnecting, setDisconnecting] = useState(false); + const disconnectPending = useRef(false); + async function disconnect() { + 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(); + } catch { + setDisconnectError("Could not clear saved credentials. Try disconnecting again."); + disconnectPending.current = false; + setDisconnecting(false); + return; + } + session.cancel(); + drafts.clear(); + props.onDisconnect(); + } + const value: SessionContext = { + session, + data, + drafts, + selections, + setSelection(id, settings) { + setSelections((current) => ({ ...current, [id]: settings })); + }, + disconnect, + disconnectError, + disconnecting, + 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 ( + props.onSelect(workspace.id)} + onCreate={() => create((workspace) => props.onSelect(workspace.id))} + onSettings={props.onSettings} + /> + ); +} + +function WorkspacesRoute(props: NativeStackScreenProps) { + return ( + + props.navigation.navigate("Conversation", { workspaceId })} + onSettings={() => props.navigation.navigate("Settings")} + /> + + ); +} + +function ScreenLayout(props: { + children: ReactNode; + workspaceId?: string; + 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 && ( + + props.navigation.navigate("Settings")} + /> + + )} + {props.children} + + ); +} + +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, selections, setSelection } = useSession(); + const streamsReconnecting = useStreamsReconnecting(); + const { workspaceId } = props.route.params; + const workspace = data.workspaces.find((item) => item.id === workspaceId); + return ( + + {(session.reconnecting || streamsReconnecting) && } + {session.error && {session.error}} + {workspace ? ( + props.navigation.popTo("Workspaces")} + onChanges={() => props.navigation.navigate("Changes", { workspaceId })} + onSettings={() => props.navigation.navigate("Settings")} + selection={selections[workspaceId] ?? null} + onSelectionChange={(value) => setSelection(workspaceId, value)} + /> + ) : ( + <> +
props.navigation.goBack()} /> + {data.loading ? ( + + ) : ( + + This workspace is no longer available. + + + )} + + )} + + ); +} + +function ChangesRoute(props: NativeStackScreenProps) { + 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 ( + + {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."} + + + + + )} + + ); +} + +function SettingsRoute(props: NativeStackScreenProps) { + const { session, disconnect, disconnectError, disconnecting } = useSession(); + return ( + + props.navigation.goBack()} + error={disconnectError} + busy={disconnecting} + /> + + ); +} diff --git a/packages/mobile/app.json b/packages/mobile/app.json new file mode 100644 index 00000000000..9b6bd49d9f6 --- /dev/null +++ b/packages/mobile/app.json @@ -0,0 +1,21 @@ +{ + "expo": { + "name": "Xum", + "slug": "xum-mobile", + "version": "0.1.0", + "orientation": "default", + "userInterfaceStyle": "dark", + "scheme": "xum-mobile", + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.coder.xum.mobile", + "infoPlist": { + "NSLocalNetworkUsageDescription": "Connect to your Xum server on your local network.", + "NSAppTransportSecurity": { "NSAllowsLocalNetworking": true } + } + }, + "android": { "package": "com.coder.xum.mobile" }, + "web": { "bundler": "metro", "output": "single", "name": "Xum Mobile" }, + "plugins": ["expo-secure-store"] + } +} diff --git a/packages/mobile/bun.lock b/packages/mobile/bun.lock new file mode 100644 index 00000000000..92e6dec72ef --- /dev/null +++ b/packages/mobile/bun.lock @@ -0,0 +1,1306 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@coder/xum-mobile", + "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", + "lucide-react-native": "^0.553.0", + "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", + "zod": "4.4.3", + }, + "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", + "@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", + "postcss": "^8.5.6", + "typescript": "~6.0.3", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg=="], + + "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-decorators": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg=="], + + "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ=="], + + "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg=="], + + "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="], + + "@babel/plugin-syntax-export-default-from": ["@babel/plugin-syntax-export-default-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw=="], + + "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="], + + "@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="], + + "@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=="], + + "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="], + + "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="], + + "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="], + + "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="], + + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="], + + "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="], + + "@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-flow": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ=="], + + "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="], + + "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-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" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="], + + "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="], + + "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="], + + "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="], + + "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="], + + "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="], + + "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug=="], + + "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@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" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA=="], + + "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], + + "@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=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@expo/cli": ["@expo/cli@57.0.22", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~57.0.9", "@expo/config-plugins": "~57.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.3", "@expo/image-utils": "^0.11.5", "@expo/inline-modules": "^0.1.7", "@expo/json-file": "^11.0.1", "@expo/log-box": "^57.0.4", "@expo/metro": "~56.0.2", "@expo/metro-config": "~57.0.12", "@expo/metro-file-map": "^57.0.2", "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", "@expo/prebuild-config": "^57.0.15", "@expo/require-utils": "^57.0.5", "@expo/router-server": "^57.0.9", "@expo/schema-utils": "^57.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.86.3", "accepts": "^1.3.8", "agent-cli-detector": "0.1.7", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^57.0.3", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.2", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "sandbox-cli-detector": "^0.2.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-MdToR0ZZ1yjfkqbP1jcA7viS9duMPGq/C+qQ/21dtq+en583Ghp+O11BP/wujHa9ZGRtXimzuKPZDpPresVE0g=="], + + "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="], + + "@expo/config": ["@expo/config@57.0.9", "", { "dependencies": { "@expo/config-plugins": "~57.0.9", "@expo/config-types": "^57.0.2", "@expo/json-file": "^11.0.1", "@expo/require-utils": "^57.0.5", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ=="], + + "@expo/config-plugins": ["@expo/config-plugins@57.0.9", "", { "dependencies": { "@expo/config-types": "^57.0.2", "@expo/json-file": "~11.0.1", "@expo/plist": "^0.8.1", "@expo/require-utils": "^57.0.5", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg=="], + + "@expo/config-types": ["@expo/config-types@57.0.2", "", {}, "sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g=="], + + "@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="], + + "@expo/devtools": ["@expo/devtools@57.0.1", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg=="], + + "@expo/dom-webview": ["@expo/dom-webview@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw=="], + + "@expo/env": ["@expo/env@2.4.3", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-M1NXeZCA1mkMkYOyIe7PlyRX0/jqFtMoJgyblnlq/vpCRfmueFT7RnGSQG8uEFDF5WHOFGijAQ3fogPh3/n5Ng=="], + + "@expo/expo-modules-macros-plugin": ["@expo/expo-modules-macros-plugin@0.6.1", "", {}, "sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA=="], + + "@expo/fingerprint": ["@expo/fingerprint@0.20.12", "", { "dependencies": { "@expo/env": "^2.4.3", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-FIR5fkZYeFaLSowmjgyB6RPKl8AXeE8HuCBHHvyxK8UhOjpPfCAmzT4E7v2yub4qqDafKnfcOFCYxvpUEu+01w=="], + + "@expo/image-utils": ["@expo/image-utils@0.11.5", "", { "dependencies": { "@expo/require-utils": "^57.0.5", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ=="], + + "@expo/inline-modules": ["@expo/inline-modules@0.1.7", "", { "dependencies": { "@expo/config-plugins": "~57.0.9" } }, "sha512-Bz/khd1gIJqDkje7t5ejD5e9jFbm4xEJzWSwRKadRo6gruepbw1xJ3Eb+e58OS8fY1ZNQYjzZbspnuph67sN0g=="], + + "@expo/json-file": ["@expo/json-file@11.0.1", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ=="], + + "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@57.0.8", "", { "dependencies": { "@expo/config": "~57.0.9", "chalk": "^4.1.2" } }, "sha512-SEdE0pAQrr90bRh3MNR0ZuwoIBw390cYdFgbn7Vk0Mtm9EHaBfP7kYj+2hXnXZJgOEm3/JMtfoD3rV7rWA9FGg=="], + + "@expo/log-box": ["@expo/log-box@57.0.4", "", { "dependencies": { "@expo/dom-webview": "^57.0.1", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-IxwS9s1L2muj8mj8AQSuiy7u8OFJdc02NRFo2me/Tj6DiaeG5SREqmpBE4rQpR2cadqSg5jl8Qab8Cjie616dg=="], + + "@expo/metro": ["@expo/metro@56.0.2", "", { "dependencies": { "metro": "0.84.5", "metro-babel-transformer": "0.84.5", "metro-cache": "0.84.5", "metro-cache-key": "0.84.5", "metro-config": "0.84.5", "metro-core": "0.84.5", "metro-file-map": "0.84.5", "metro-minify-terser": "0.84.5", "metro-resolver": "0.84.5", "metro-runtime": "0.84.5", "metro-source-map": "0.84.5", "metro-symbolicate": "0.84.5", "metro-transform-plugins": "0.84.5", "metro-transform-worker": "0.84.5" } }, "sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA=="], + + "@expo/metro-config": ["@expo/metro-config@57.0.12", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~57.0.9", "@expo/env": "~2.4.3", "@expo/json-file": "~11.0.1", "@expo/metro": "~56.0.2", "@expo/require-utils": "^57.0.5", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.36.0", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-S62Lrq35HZqBFD55423pmWb8PjaiR/W02zQC1uECBmw1vTN8WZaFz4TJ0i21EeJzwfebMb9MLxL8JZ102Z6VbA=="], + + "@expo/metro-file-map": ["@expo/metro-file-map@57.0.2", "", { "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "sha512-tb50nSIWwKpRufSkGuivOK0FbUxv1Uwqptb0SzFTk0bkmYmcy3gIwnCbOHTfmrQDVndRhPEiu2SYgh9Z0PCvGg=="], + + "@expo/metro-runtime": ["@expo/metro-runtime@57.0.15", "", { "dependencies": { "@expo/log-box": "^57.0.4", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-9RaFkqIzRud6ol9fc5ZKHgNDYeDjQVBdAQC+rjSQ6RiKV0pY+SjDqGmKY5a4M9TaV0qLMCNNrp5R88BqC2xbuQ=="], + + "@expo/osascript": ["@expo/osascript@2.7.1", "", { "dependencies": { "@expo/spawn-async": "^1.8.0" } }, "sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g=="], + + "@expo/package-manager": ["@expo/package-manager@1.13.1", "", { "dependencies": { "@expo/json-file": "^11.0.1", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg=="], + + "@expo/plist": ["@expo/plist@0.8.1", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw=="], + + "@expo/prebuild-config": ["@expo/prebuild-config@57.0.15", "", { "dependencies": { "@expo/config": "~57.0.9", "@expo/config-plugins": "~57.0.9", "@expo/config-types": "^57.0.2", "@expo/image-utils": "^0.11.5", "@expo/json-file": "^11.0.1", "@react-native/normalize-colors": "0.86.3", "debug": "^4.3.1", "expo-modules-autolinking": "~57.0.12", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-xTbWHroj0PDmlbqvmU+zF9ZZxveJkiuyiPoeRJYRGruFHebRAWnoTdw5S7d/UCzDBI8ropGGu9g2eb2nMxtvAw=="], + + "@expo/require-utils": ["@expo/require-utils@57.0.5", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw=="], + + "@expo/router-server": ["@expo/router-server@57.0.9", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^57.0.15", "expo": "*", "expo-constants": "^57.0.17", "expo-font": "^57.0.3", "expo-router": "*", "expo-server": "^57.0.3", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-/PxRQozFesIyCJZOAtrQE8XcmcojNiL5ctPMQnbE4ojC2EJPu0zc7c0Y4PuXsoRxrZxm8Usw76/lCVrXcfTZ2w=="], + + "@expo/schema-utils": ["@expo/schema-utils@57.0.2", "", {}, "sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw=="], + + "@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="], + + "@expo/spawn-async": ["@expo/spawn-async@1.8.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw=="], + + "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="], + + "@expo/ws-tunnel": ["@expo/ws-tunnel@2.0.0", "", { "peerDependencies": { "ws": "^8.0.0" } }, "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw=="], + + "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="], + + "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], + + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@orpc/client": ["@orpc/client@1.14.11", "", { "dependencies": { "@orpc/shared": "1.14.11", "@standardserver/core": "^0.5.0", "@standardserver/fetch": "^0.5.0", "@standardserver/peer": "^0.5.0" } }, "sha512-ZD2r8x07bycoqI/nGGFmIR1n4m/Mf2TsfVTPtuo5djKfWfSXmQPPAXhZsO6CN3eF+sl6g4sa5/fe1/T5zQoE1w=="], + + "@orpc/contract": ["@orpc/contract@1.14.11", "", { "dependencies": { "@orpc/client": "1.14.11", "@orpc/shared": "1.14.11", "@standard-schema/spec": "^1.1.0" } }, "sha512-TM5Bw5adKuxMUJWENYzVwaDVN8eamwF/+PV5xkCGCaKuyZXHlWCDFhoGOQN8ybbrEtlReT/QPUtDclLhM9Ny1A=="], + + "@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=="], + + "@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=="], + + "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.86.3", "", {}, "sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw=="], + + "@react-native/debugger-shell": ["@react-native/debugger-shell@0.86.3", "", { "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", "fb-dotslash": "0.5.8" } }, "sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ=="], + + "@react-native/dev-middleware": ["@react-native/dev-middleware@0.86.3", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.86.3", "@react-native/debugger-shell": "0.86.3", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA=="], + + "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.86.3", "", {}, "sha512-lxmx0GqLEWRIpZfpYFXlYVIs3ENQwaW6Vmp6oi29l2GoQJ1wZfFZRdMimDWlGEk8LKfHar3QH3iaPMkTcK9lEQ=="], + + "@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=="], + + "@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=="], + + "@standardserver/core": ["@standardserver/core@0.5.0", "", { "dependencies": { "@standardserver/shared": "0.5.0" } }, "sha512-c6vIXiJtQNVHm0G8vJCCdZ7G4ObgWj71n89HvKvVR5RnajxEEb+wVcIn10rvrMJmd9sR9RtHR6HCubeCFY214A=="], + + "@standardserver/fetch": ["@standardserver/fetch@0.5.0", "", { "dependencies": { "@standardserver/core": "0.5.0", "@standardserver/shared": "0.5.0" } }, "sha512-0Sosn0v2Cl2Vhcc1st2FKGxwdK0qPq84K40TtZFAePLGq+Zu6hJZ4VsrwOWfm3S7iZ0EeRo9OByp0oUhm8NiPw=="], + + "@standardserver/peer": ["@standardserver/peer@0.5.0", "", { "dependencies": { "@standardserver/core": "0.5.0", "@standardserver/shared": "0.5.0" } }, "sha512-gsOgvknhj8XirAqJksDZlrzkppGU2WH4PyxzISwfG8Ug3TJyOYwWvNCY81HNe5PJWjDdelIkgHSyjb2apkN2CA=="], + + "@standardserver/shared": ["@standardserver/shared@0.5.0", "", {}, "sha512-YPkkdqyKQ+6tvtP2pPLzIIrvhjrsvnI006tTTSWqFc2US6X8r1zfYVhfHf9Zpn4HsdhyhwpWwF4fIsN+nlr1Ow=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/react": ["@testing-library/react@16.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], + + "@types/http-proxy": ["@types/http-proxy@1.17.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw=="], + + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + + "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "agent-cli-detector": ["agent-cli-detector@0.1.7", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng=="], + + "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], + + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], + + "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], + + "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="], + + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + + "babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="], + + "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.0", "", { "dependencies": { "hermes-parser": "0.36.0" } }, "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q=="], + + "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], + + "babel-preset-expo": ["babel-preset-expo@57.0.10", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@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-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^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-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@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.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.86.3", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.36.0", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^57.0.16", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-08bt6bqMZFoQuWO9gkt0EXotbqKHimELFGa1LlKJvY89iKsi4S0FJSFBFJ4pn9NZvofet48HIjYDQ0SZJ7bNrg=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="], + + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], + + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="], + + "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], + + "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="], + + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + + "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "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=="], + + "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + + "connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="], + + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="], + + "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "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=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dnssd-advertise": ["dnssd-advertise@1.1.6", "", {}, "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.422", "", {}, "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "expo": ["expo@57.0.20", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.22", "@expo/config": "~57.0.9", "@expo/config-plugins": "~57.0.9", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", "@expo/fingerprint": "^0.20.12", "@expo/local-build-cache-provider": "^57.0.8", "@expo/log-box": "^57.0.4", "@expo/metro": "~56.0.2", "@expo/metro-config": "~57.0.12", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.10", "expo-asset": "~57.0.16", "expo-constants": "~57.0.17", "expo-file-system": "~57.0.6", "expo-font": "~57.0.3", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.12", "expo-modules-core": "~57.0.16", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-H57GWLLpNPrltsrjvx6ROsMecoFQza7h9OzujUjBsaY+aDL+bj3GsZaH3T5v5B96R7txuidUwjUh5q5hi3qttg=="], + + "expo-asset": ["expo-asset@57.0.16", "", { "dependencies": { "@expo/image-utils": "^0.11.5", "expo-constants": "~57.0.17" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-IBRfQdW3iFT+GOBERMZLZM1MUNyrjMgMskuD0elVZ1ae44858UFlTJkHO3f8vi+u5zuv7O7KofsiN8NMG/uWzw=="], + + "expo-constants": ["expo-constants@57.0.17", "", { "dependencies": { "@expo/env": "~2.4.3" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-cPWYBKN1SEbg2lXg2f8VkePJqGZrJPLvVdQDCTfbFu9sQHO1M31Y1zILReUuGnmt/VKeUz40ze579vR00xGu/A=="], + + "expo-file-system": ["expo-file-system@57.0.6", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-pm8PMYEW6BnVOCBJ7df9FcDmQtE1tqImuYphlfYe1ipQRLYtdCayRczJcbKIsnB3mqEyp4gC2gWmMbsAsAOjVg=="], + + "expo-font": ["expo-font@57.0.3", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-kiVUnc2A8vAvO2FfDJTsQa5BwmY+PAkof/1wRb5MOkcX1jtiaSTwz9gCUAyscMBFLundZDmZrLy1P7LZVC+NvA=="], + + "expo-keep-awake": ["expo-keep-awake@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-28lkFImeXTS+bhAjuCFV7w7tW5bXg27BJVrxv+nC/nyYa86qEa0oFeHwqol6ha5k4pdVDQgBF09GM4A1k76Ssg=="], + + "expo-modules-autolinking": ["expo-modules-autolinking@57.0.12", "", { "dependencies": { "@expo/require-utils": "^57.0.5", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-Q8KAlq37nLKsQ+HsS9NpQVpd5jCgqtu694TDUNHBUBpV9ViD82mRBh8Uug/h68RG9xnLS+kuL4nYaCuFRghHjg=="], + + "expo-modules-core": ["expo-modules-core@57.0.16", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", "expo-modules-jsi": "~57.0.8", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-vPbxDOdvG6Ucpws5ucdbQa+nNmdxKONS1fTg92sy0p4TnHX8hBiExtbrtUA/5+1t3FRCUuiy29/1nJEGdt3E4A=="], + + "expo-modules-jsi": ["expo-modules-jsi@57.0.8", "", { "peerDependencies": { "react-native": "*" } }, "sha512-MFwvKP0FXLkEET27KsKvOh/L7Dpu2d00UgzAlmKozJgtI23WTKSyKPxu+EOSs9peSgkHzKI0vQOhWjOseGBMkQ=="], + + "expo-secure-store": ["expo-secure-store@57.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-w7XkSQeUiYXPoKXo1jSrQqql7pyCSyIzOp2k0apsZZBE+RkoLTQIMjcbqc181y6KgupfNnYxkaKe+4MEg94+SA=="], + + "expo-server": ["expo-server@57.0.3", "", {}, "sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ=="], + + "expo-status-bar": ["expo-status-bar@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg=="], + + "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=="], + + "fbjs": ["fbjs@3.0.5", "", { "dependencies": { "cross-fetch": "^3.1.5", "fbjs-css-vars": "^1.0.0", "loose-envify": "^1.0.0", "object-assign": "^4.1.0", "promise": "^7.1.1", "setimmediate": "^1.0.5", "ua-parser-js": "^1.0.35" } }, "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg=="], + + "fbjs-css-vars": ["fbjs-css-vars@1.0.2", "", {}, "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-nodeshim": ["fetch-nodeshim@0.4.10", "", {}, "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w=="], + + "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=="], + + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="], + + "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=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "happy-dom": ["happy-dom@20.0.10", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hermes-compiler": ["hermes-compiler@250829098.0.17", "", {}, "sha512-qG1PXzTEtriF6oQLZF3vyHhSMxOdW5h2TqqLri0rdpstPustd2fSvRZQMVAPdlhgFwBfYnj3OUZtiO6LjYsEFw=="], + + "hermes-estree": ["hermes-estree@0.36.1", "", {}, "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw=="], + + "hermes-parser": ["hermes-parser@0.36.1", "", { "dependencies": { "hermes-estree": "0.36.1" } }, "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ=="], + + "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], + + "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=="], + + "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=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], + + "jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], + + "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], + + "jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + + "jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "lan-network": ["lan-network@0.2.1", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A=="], + + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + + "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + + "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], + + "log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], + + "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@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=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], + + "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + + "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="], + + "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "metro": ["metro@0.84.5", "", { "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.35.0", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.5", "metro-cache": "0.84.5", "metro-cache-key": "0.84.5", "metro-config": "0.84.5", "metro-core": "0.84.5", "metro-file-map": "0.84.5", "metro-resolver": "0.84.5", "metro-runtime": "0.84.5", "metro-source-map": "0.84.5", "metro-symbolicate": "0.84.5", "metro-transform-plugins": "0.84.5", "metro-transform-worker": "0.84.5", "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-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw=="], + + "metro-babel-transformer": ["metro-babel-transformer@0.84.5", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.84.5", "nullthrows": "^1.1.1" } }, "sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ=="], + + "metro-cache": ["metro-cache@0.84.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.84.5" } }, "sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ=="], + + "metro-cache-key": ["metro-cache-key@0.84.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ=="], + + "metro-config": ["metro-config@0.84.5", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.84.5", "metro-cache": "0.84.5", "metro-core": "0.84.5", "metro-runtime": "0.84.5", "yaml": "^2.6.1" } }, "sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA=="], + + "metro-core": ["metro-core@0.84.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.84.5" } }, "sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA=="], + + "metro-file-map": ["metro-file-map@0.84.5", "", { "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-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA=="], + + "metro-minify-terser": ["metro-minify-terser@0.84.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg=="], + + "metro-resolver": ["metro-resolver@0.84.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA=="], + + "metro-runtime": ["metro-runtime@0.84.5", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg=="], + + "metro-source-map": ["metro-source-map@0.84.5", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.84.5", "nullthrows": "^1.1.1", "ob1": "0.84.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg=="], + + "metro-symbolicate": ["metro-symbolicate@0.84.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.84.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw=="], + + "metro-transform-plugins": ["metro-transform-plugins@0.84.5", "", { "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-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg=="], + + "metro-transform-worker": ["metro-transform-worker@0.84.5", "", { "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.84.5", "metro-babel-transformer": "0.84.5", "metro-cache": "0.84.5", "metro-cache-key": "0.84.5", "metro-minify-terser": "0.84.5", "metro-source-map": "0.84.5", "metro-transform-plugins": "0.84.5", "nullthrows": "^1.1.1" } }, "sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "multitars": ["multitars@1.0.2", "", {}, "sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ=="], + + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + + "node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="], + + "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], + + "ob1": ["ob1@0.84.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + + "onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], + + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + + "ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], + + "parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "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=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], + + "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=="], + + "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=="], + + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], + + "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-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=="], + + "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=="], + + "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=="], + + "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], + + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + + "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], + + "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], + + "regjsparser": ["regjsparser@0.13.2", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + + "resolve-workspace-root": ["resolve-workspace-root@2.0.1", "", {}, "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w=="], + + "restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "sandbox-cli-detector": ["sandbox-cli-detector@0.2.0", "", { "bin": { "sandbox-cli-detector": "dist/cli.js" } }, "sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + + "serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], + + "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + + "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=="], + + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "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=="], + + "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "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=="], + + "structured-headers": ["structured-headers@0.4.1", "", {}, "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg=="], + + "styleq": ["styleq@0.1.3", "", {}, "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], + + "terser": ["terser@5.51.2", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q=="], + + "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "toqr": ["toqr@0.1.1", "", {}, "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "type-fest": ["type-fest@5.9.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], + + "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], + + "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], + + "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "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=="], + + "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], + + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], + + "warn-once": ["warn-once@0.1.1", "", {}, "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q=="], + + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + + "web-streams-polyfill": ["web-streams-polyfill@4.2.0", "", {}, "sha512-0rYDzGOh9EZpig92umN5g5D/9A1Kff7k0/mzPSSCY8jEQeYkgRMoY7LhbXtUCWzLCMX0TUE9aoHkjFNB7D9pfA=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + + "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], + + "xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="], + + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "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=="], + + "@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=="], + + "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@expo/cli/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=="], + + "@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=="], + + "@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=="], + + "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + + "babel-preset-expo/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.1", "", { "dependencies": { "hermes-parser": "0.36.1" } }, "sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "compression/negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "expo/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=="], + + "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], + + "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + + "finalhandler/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], + + "finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], + + "happy-dom/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + + "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "jest-validate/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=="], + + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "log-symbols/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=="], + + "metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "metro/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], + + "metro/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "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=="], + + "react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="], + + "react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], + + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "stacktrace-parser/type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], + + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "@expo/cli/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "@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=="], + + "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "expo/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "log-symbols/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], + + "metro/accepts/negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], + + "metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "react-native/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "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=="], + + "ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "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/e2e/native-ux.spec.ts b/packages/mobile/e2e/native-ux.spec.ts new file mode 100644 index 00000000000..b46df1a9e8f --- /dev/null +++ b/packages/mobile/e2e/native-ux.spec.ts @@ -0,0 +1,192 @@ +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(); + 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 }); + 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 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); + 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); + + const chooseModel = page.getByRole("button", { name: "Choose model", exact: true }); + await chooseModel.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"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + const modelBeforeModeChange = await chooseModel.textContent(); + await page.getByRole("button", { name: "Choose mode", exact: true }).click(); + await page.getByRole("radio", { name: /^Plan/ }).click(); + 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" + ); + 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 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(""); + await message.blur(); + 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 + ); + // 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) => + [...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/eslint.config.mjs b/packages/mobile/eslint.config.mjs new file mode 100644 index 00000000000..7698be72114 --- /dev/null +++ b/packages/mobile/eslint.config.mjs @@ -0,0 +1,26 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; + +export default tseslint.config( + { + ignores: ["node_modules/**", "dist/**", ".expo/**", "ios/**", "android/**", "metro.config.cjs"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + // Metro loads this build-time generator synchronously from its CommonJS config. + files: ["scripts/generateTheme.cjs"], + languageOptions: { sourceType: "commonjs", globals: { __dirname: "readonly" } }, + rules: { "@typescript-eslint/no-require-imports": "off" }, + }, + { + files: ["**/*.{ts,tsx}"], + plugins: { "react-hooks": reactHooks }, + rules: { + ...reactHooks.configs.recommended.rules, + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "error", + }, + } +); diff --git a/packages/mobile/index.ts b/packages/mobile/index.ts new file mode 100644 index 00000000000..580ea106555 --- /dev/null +++ b/packages/mobile/index.ts @@ -0,0 +1,6 @@ +import "@expo/metro-runtime"; +import { registerRootComponent } from "expo"; +import "./src/polyfills"; +import App from "./App"; + +registerRootComponent(App); diff --git a/packages/mobile/metro.config.cjs b/packages/mobile/metro.config.cjs new file mode 100644 index 00000000000..3a5d8d7332b --- /dev/null +++ b/packages/mobile/metro.config.cjs @@ -0,0 +1,18 @@ +const path = require("node:path"); +const { getDefaultConfig } = require("expo/metro-config"); +const { ensureTheme } = require("./scripts/generateTheme.cjs"); + +// Direct Expo invocations must refresh local colors too; CI only checks for drift. +ensureTheme(); +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"), +]; +config.resolver.disableHierarchicalLookup = true; +config.resolver.blockList = [/\/\.git\//]; +module.exports = config; diff --git a/packages/mobile/package.json b/packages/mobile/package.json new file mode 100644 index 00000000000..5112c552107 --- /dev/null +++ b/packages/mobile/package.json @@ -0,0 +1,45 @@ +{ + "name": "@coder/xum-mobile", + "version": "0.1.0", + "private": true, + "main": "index.ts", + "license": "AGPL-3.0-only", + "packageManager": "bun@1.3.5", + "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", + "lucide-react-native": "^0.553.0", + "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", + "zod": "4.4.3" + }, + "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", + "@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", + "postcss": "^8.5.6", + "typescript": "~6.0.3" + } +} 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/scripts/generateTheme.cjs b/packages/mobile/scripts/generateTheme.cjs new file mode 100644 index 00000000000..3cd076c3bde --- /dev/null +++ b/packages/mobile/scripts/generateTheme.cjs @@ -0,0 +1,156 @@ +const assert = require("node:assert/strict"); +const { readFileSync, writeFileSync } = require("node:fs"); +const path = require("node:path"); +const process = require("node:process"); +const postcss = require("postcss"); + +// CSS owns every hue. These opacity levels describe mobile surface treatments, +// so changing an accent in the canonical theme also changes its translucent surface. +const mobileTokens = { + background: "--color-background", + panel: "--color-surface-tertiary", + elevated: "--color-bg-subtle", + border: "--color-border-medium", + text: "--color-lighter", + bright: "--color-content-primary", + muted: "--color-content-secondary", + dim: "--color-dim", + sheet: "--color-darker", + selection: "--color-plan-mode-light", + accent: "--color-exec-mode", + accentSurface: { token: "--color-exec-mode", alpha: 0.12 }, + plan: "--color-plan-mode-light", + danger: "--color-danger-light", + dangerSurface: { token: "--color-danger-light", alpha: 0.1 }, + warning: "--color-edit-mode-light", + warningSurface: { token: "--color-edit-mode-light", alpha: 0.1 }, + success: "--color-content-success", + user: "--color-user-surface", + scrim: "--color-overlay-scrim", +}; + +/** @param {string} value @param {number} maximum */ +function channel(value, maximum) { + assert(/^[+-]?(?:\d+\.?\d*|\.\d+)%?$/.test(value), `Unsupported color channel: ${value}`); + const number = Number.parseFloat(value) * (value.endsWith("%") ? maximum / 100 : 1); + assert(number >= 0 && number <= maximum, `Color channel out of range: ${value}`); + return number; +} + +/** @param {string} value @returns {number[]} */ +function rgba(value) { + const hex = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(value); + if (hex) { + const bytes = hex[1].length < 5 ? [...hex[1]].map((digit) => digit + digit).join("") : hex[1]; + return [0, 2, 4] + .map((offset) => Number.parseInt(bytes.slice(offset, offset + 2), 16)) + .concat(bytes.length === 8 ? Number.parseInt(bytes.slice(6), 16) / 255 : 1); + } + const match = /^(hsla?|rgba?)\(([^()]*)\)$/i.exec(value); + assert(match, `Unsupported native theme color: ${value}`); + const body = match[2].trim(); + const parts = body.includes(",") ? body.split(/\s*,\s*/) : body.split(/\s*\/\s*/); + const channels = body.includes(",") ? parts.slice(0, 3) : parts[0].split(/\s+/); + const alpha = body.includes(",") ? parts[3] : parts[1]; + assert( + channels.length === 3 && parts.length <= (body.includes(",") ? 4 : 2), + `Unsupported color: ${value}` + ); + let rgb; + if (match[1].toLowerCase().startsWith("hsl")) { + assert(/^[+-]?(?:\d+\.?\d*|\.\d+)(?:deg)?$/.test(channels[0]), `Unsupported hue: ${value}`); + assert(channels[1].endsWith("%") && channels[2].endsWith("%"), `Unsupported HSL: ${value}`); + const degrees = Number.parseFloat(channels[0]); + assert(Number.isFinite(degrees), `Unsupported hue: ${value}`); + const hue = (((degrees % 360) + 360) % 360) / 30; + const saturation = channel(channels[1], 100) / 100; + const lightness = channel(channels[2], 100) / 100; + const amplitude = saturation * Math.min(lightness, 1 - lightness); + rgb = [0, 8, 4].map((offset) => { + const k = (offset + hue) % 12; + return 255 * (lightness - amplitude * Math.max(-1, Math.min(k - 3, 9 - k, 1))); + }); + } else { + rgb = channels.map((component) => channel(component, 255)); + } + return [...rgb.map(Math.round), alpha === undefined ? 1 : channel(alpha, 1)]; +} + +/** + * Only the canonical dark :root @theme participates, never platform/theme overrides. + * @param {string} css + * @param {Record} [tokens] + * @returns {Record} + */ +function projectTheme(css, tokens = mobileTokens) { + const themes = postcss + .parse(css) + .nodes.filter((node) => node.type === "rule" && node.selector === ":root") + .flatMap((node) => + node.nodes.filter((child) => child.type === "atrule" && child.name === "theme") + ); + assert(themes.length === 1, "Expected exactly one canonical :root @theme"); + const values = new Map( + themes[0].nodes.filter((node) => node.type === "decl").map((node) => [node.prop, node.value]) + ); + /** @param {string} token @param {Set} [visiting] @returns {number[]} */ + function resolve(token, visiting = new Set()) { + assert(!visiting.has(token), `Theme alias cycle: ${token}`); + const value = values.get(token); + assert(value !== undefined, `Missing theme token: ${token}`); + const alias = /^var\(\s*(--[\w-]+)\s*\)$/.exec(value); + return alias ? resolve(alias[1], new Set([...visiting, token])) : rgba(value.trim()); + } + return Object.fromEntries( + Object.entries(tokens).map(([key, entry]) => { + const { token, alpha } = + typeof entry === "string" ? { token: entry, alpha: undefined } : entry; + const color = resolve(token); + if (alpha !== undefined) color[3] = channel(String(alpha), 1); + return [key, `rgba(${color.map((component) => Number(component.toFixed(6))).join(", ")})`]; + }) + ); +} + +/** @param {{ sourcePath?: string, outputPath?: string, check?: boolean }} [options] */ +function ensureTheme(options = {}) { + const repositoryRoot = path.resolve(__dirname, "../../.."); + const sourcePath = + options.sourcePath ?? path.join(repositoryRoot, "src/browser/styles/globals.css"); + const outputPath = + options.outputPath ?? + path.join(repositoryRoot, "src/common/constants/mobileThemeColors.generated.ts"); + const colors = projectTheme(readFileSync(sourcePath, "utf8")); + const content = + "// Generated from globals.css by packages/mobile/scripts/generateTheme.cjs.\n" + + "// Run the generator with --write to refresh; do not edit this palette manually.\n" + + "export const mobileThemeColors = {\n" + + Object.entries(colors) + .map(([key, color]) => ` ${key}: ${JSON.stringify(color)},\n`) + .join("") + + "} as const;\n"; + let current; + try { + current = readFileSync(outputPath, "utf8"); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (current === content) return; + assert( + !(options.check ?? Boolean(process.env.CI)), + "Native theme projection is stale; run node packages/mobile/scripts/generateTheme.cjs --write" + ); + writeFileSync(outputPath, content); +} + +module.exports = { projectTheme, ensureTheme }; + +if (require.main === module) { + const mode = process.argv[2]; + assert( + process.argv.length === 3 && ["--write", "--check"].includes(mode), + "Usage: node packages/mobile/scripts/generateTheme.cjs --write|--check" + ); + // CI must diagnose drift, never conceal it by regenerating the committed projection. + ensureTheme({ check: Boolean(process.env.CI) || mode === "--check" }); +} diff --git a/packages/mobile/scripts/generateTheme.test.ts b/packages/mobile/scripts/generateTheme.test.ts new file mode 100644 index 00000000000..fbb19911406 --- /dev/null +++ b/packages/mobile/scripts/generateTheme.test.ts @@ -0,0 +1,87 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureTheme, projectTheme } from "./generateTheme.cjs"; + +const temporaryDirectories: string[] = []; +afterEach(() => + temporaryDirectories.splice(0).forEach((path) => rmSync(path, { recursive: true })) +); + +function project(declarations: string) { + return projectTheme(`:root { @theme { ${declarations} } }`, { accent: "--accent" }).accent; +} + +test("canonical edits and recursive aliases propagate while theme overrides stay excluded", () => { + const css = ":root { @theme { --base: #123; --alias: var(--base); --accent: var(--alias); } }"; + expect(projectTheme(css, { accent: "--accent" }).accent).toBe("rgba(17, 34, 51, 1)"); + expect(projectTheme(css.replace("#123", "#456"), { accent: "--accent" }).accent).toBe( + "rgba(68, 85, 102, 1)" + ); + expect( + projectTheme(css + ':root[data-theme="light"] { --base: #fff; }', { accent: "--accent" }).accent + ).toBe("rgba(17, 34, 51, 1)"); +}); + +test.each([ + ["#1234", "rgba(17, 34, 51, 0.266667)"], + ["#12345680", "rgba(18, 52, 86, 0.501961)"], + ["hsl(210 100% 50% / 25%)", "rgba(0, 128, 255, 0.25)"], + ["hsla(210, 100%, 50%, 0.4)", "rgba(0, 128, 255, 0.4)"], + ["hsla(0, 0%, 50%)", "rgba(128, 128, 128, 1)"], + ["rgb(100% 0% 0% / .2)", "rgba(255, 0, 0, 0.2)"], + ["rgba(1, 2, 3, 0.75)", "rgba(1, 2, 3, 0.75)"], + ["hsl(-120deg 100% 50%)", "rgba(0, 0, 255, 1)"], +])("normalizes %s into native-safe color syntax", (input, expected) => { + expect(project(`--accent: ${input};`)).toBe(expected); +}); + +test("semantic opacity derives its hue from the canonical token", () => { + const tokens = { surface: { token: "--accent", alpha: 0.12 } }; + const css = ":root { @theme { --accent: #123; } }"; + expect(projectTheme(css, tokens).surface).toBe("rgba(17, 34, 51, 0.12)"); + expect(projectTheme(css.replace("#123", "#abc"), tokens).surface).toBe( + "rgba(170, 187, 204, 0.12)" + ); +}); + +test.each([ + ["", /Missing theme token/], + ["--accent: var(--missing);", /Missing theme token/], + ["--accent: var(--alias); --alias: var(--accent);", /cycle/], + ["--accent: color-mix(in srgb, red, blue);", /Unsupported/], + ["--accent: hsl(from red h s l);", /Unsupported/], + ["--accent: rgba(0, 0, 0, 2);", /range/], + ["--accent: hsl(0 101% 50%);", /range/], + [`--accent: hsl(${"9".repeat(400)} 100% 50%);`, /Unsupported hue/], +])("rejects invalid projected declarations: %s", (declarations, error) => { + expect(() => project(declarations)).toThrow(error); +}); + +test("drift checks never mutate output, and write mode leaves current files untouched", () => { + const directory = mkdtempSync(join(tmpdir(), "mobile-theme-test-")); + temporaryDirectories.push(directory); + const sourcePath = join(directory, "globals.css"); + const outputPath = join(directory, "colors.ts"); + const css = readFileSync( + new URL("../../../src/browser/styles/globals.css", import.meta.url), + "utf8" + ); + writeFileSync(sourcePath, css); + expect(() => ensureTheme({ sourcePath, outputPath, check: true })).toThrow(/stale/); + ensureTheme({ sourcePath, outputPath, check: false }); + const original = readFileSync(outputPath, "utf8"); + const modified = statSync(outputPath).mtimeMs; + ensureTheme({ sourcePath, outputPath, check: false }); + expect(statSync(outputPath).mtimeMs).toBe(modified); + ensureTheme({ sourcePath, outputPath, check: true }); + writeFileSync( + sourcePath, + css.replace(/--color-exec-mode:\s*[^;]+;/, "--color-exec-mode: #abcdef;") + ); + expect(() => ensureTheme({ sourcePath, outputPath, check: true })).toThrow(/stale/); + expect(readFileSync(outputPath, "utf8")).toBe(original); + ensureTheme({ sourcePath, outputPath, check: false }); + expect(readFileSync(outputPath, "utf8")).not.toBe(original); +}); diff --git a/packages/mobile/scripts/preview.test.ts b/packages/mobile/scripts/preview.test.ts new file mode 100644 index 00000000000..4a9f4de2576 --- /dev/null +++ b/packages/mobile/scripts/preview.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { createPreviewServer } from "./preview"; + +const servers: http.Server[] = []; +async function listen(server: http.Server): Promise { + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; +} +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }) + ) + ); +}); + +const origin = "http://127.0.0.1:8082"; +const host = "127.0.0.1:8082"; + +describe("fixed-target mobile preview", () => { + test("forwards the configured path prefix and auth, but not browser identity", async () => { + const endpoint = await listen( + http.createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ url: req.url, headers: req.headers })); + }) + ); + const preview = await listen( + createPreviewServer({ endpoint: `${endpoint}/@me/dev/apps/xum`, origin }) + ); + const result = await fetch(`${preview}/__xum/orpc/workspace/list`, { + method: "POST", + headers: { + host, + origin, + Authorization: "Bearer test-only", + Cookie: "secret-preview-cookie", + "X-Forwarded-Host": "attacker.test", + Forwarded: "host=attacker.test", + }, + }); + const body = await result.json(); + expect(result.status).toBe(200); + 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(); + expect(body.headers.forwarded).toBeUndefined(); + expect(body.headers["x-forwarded-host"]).toBeUndefined(); + }); + + test("rejects cross-origin and DNS-rebound callers before hitting upstream", async () => { + let requests = 0; + const endpoint = await listen( + http.createServer((_req, res) => { + requests++; + res.end(); + }) + ); + const preview = await listen(createPreviewServer({ endpoint, origin })); + const rejectedHeaders: Array> = [ + { host, origin: "https://attacker.test" }, + { host: "attacker.test", origin }, + { host, "sec-fetch-site": "cross-site" }, + ]; + for (const headers of rejectedHeaders) { + expect((await fetch(`${preview}/__xum/orpc`, { headers })).status).toBe(403); + } + expect(requests).toBe(0); + }); + + test("exposes only the configured endpoint and refuses arbitrary proxy routes", async () => { + const endpoint = "https://xum.example.com/prefix"; + const preview = await listen(createPreviewServer({ endpoint, origin })); + const result = await fetch(`${preview}/__xum`, { headers: { host } }); + expect(await result.json()).toEqual({ endpoint }); + expect( + (await fetch(`${preview}/__xum/https://attacker.test`, { headers: { host } })).status + ).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(); +}); + +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 new file mode 100644 index 00000000000..826a9348e77 --- /dev/null +++ b/packages/mobile/scripts/preview.ts @@ -0,0 +1,128 @@ +import http from "node:http"; +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import httpProxy from "http-proxy"; +import { normalizeEndpoint } from "../src/endpoint"; + +export interface PreviewOptions { + endpoint: string; + origin: string; + metro?: string; + staticDirectory?: string; +} + +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 }); + 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) && + req.headers["sec-fetch-site"] !== "cross-site"; + + function route(req: http.IncomingMessage): string | null { + const pathname = (req.url ?? "/").split("?")[0]; + 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. + for (const key of Object.keys(req.headers)) { + if ( + key.startsWith("x-forwarded-") || + ["cookie", "forwarded", "referer", "origin"].includes(key) + ) + delete req.headers[key]; + } + req.headers.origin = target.origin; + return target.origin; + } + if (pathname?.startsWith("/__xum")) return null; + return options.staticDirectory ? null : (options.metro ?? "http://127.0.0.1:8081"); + } + + const server = http.createServer((req, res) => { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (!allowed(req)) { + res.writeHead(403).end("Preview origin rejected"); + return; + } + if (req.method === "GET" && req.url === "/__xum") { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ endpoint })); + return; + } + const upstream = route(req); + if (upstream) { + proxy.web(req, res, { target: upstream }, () => { + if (!res.headersSent) res.writeHead(502); + res.end("Preview upstream unavailable"); + }); + return; + } + if (options.staticDirectory && !req.url?.startsWith("/__xum") && req.method === "GET") { + serveStatic(options.staticDirectory, req.url ?? "/", res).catch(() => + res.writeHead(404).end("Not found") + ); + return; + } + res.writeHead(404).end("Not found"); + }); + server.on("close", () => proxy.close()); + return server; +} + +async function serveStatic(directory: string, url: string, res: http.ServerResponse) { + const root = path.resolve(directory); + const pathname = decodeURIComponent(new URL(url, "http://localhost").pathname); + const file = path.resolve(root, `.${pathname === "/" ? "/index.html" : pathname}`); + if (!file.startsWith(`${root}${path.sep}`)) { + res.writeHead(403).end(); + return; + } + const data = await readFile(file); + const types: Record = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + }; + res.setHeader("Content-Type", types[path.extname(file)] ?? "application/octet-stream"); + res.end(data); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const endpoint = process.env.XUM_MOBILE_ENDPOINT; + if (!endpoint) + throw new Error( + "Set XUM_MOBILE_ENDPOINT to the running Xum server URL (never include a token)." + ); + const port = Number(process.env.XUM_MOBILE_PORT ?? "8082"); + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error("Invalid XUM_MOBILE_PORT"); + const origin = process.env.XUM_MOBILE_ORIGIN ?? `http://127.0.0.1:${port}`; + const server = createPreviewServer({ + endpoint, + origin, + metro: process.env.XUM_MOBILE_METRO, + staticDirectory: process.env.XUM_MOBILE_STATIC_DIR, + }); + server.listen(port, "127.0.0.1", () => console.log(`Xum mobile preview: ${origin}`)); + for (const signal of ["SIGTERM", "SIGINT"] as const) + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/packages/mobile/scripts/server.integration.test.ts b/packages/mobile/scripts/server.integration.test.ts new file mode 100644 index 00000000000..c3dd55806ad --- /dev/null +++ b/packages/mobile/scripts/server.integration.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test"; +import { connect } from "../src/api"; +import { applyChatEvent, createTranscriptState } from "../src/transcript"; + +// Opt in only against a disposable XUM_MOCK_AI=1 server. All RPC, persistence, +// authentication and replay paths are real; only model output is deterministic. +const endpoint = process.env.XUM_MOBILE_TEST_ENDPOINT; +const token = process.env.XUM_MOBILE_TEST_TOKEN; + +test.skipIf(!endpoint || !token)( + "real server persists chat and replays it through a new connection", + async () => { + const connection = await connect(endpoint!, token!); + let workspaceId: string | undefined; + const subscription = new AbortController(); + try { + const created = await connection.client.workspace.createScratch({ + title: "Mobile protocol integration", + }); + if (!created.success) throw new Error(created.error); + workspaceId = created.metadata.id; + const events = await connection.client.workspace.onChat( + { workspaceId, mode: { type: "full" } }, + { signal: subscription.signal } + ); + let state = createTranscriptState(); + for await (const event of events) { + state = applyChatEvent(state, event); + if (state.caughtUp) break; + } + expect(state.caughtUp).toBe(true); + + const live = await connection.client.workspace.onChat( + { workspaceId, mode: { type: "full" } }, + { signal: subscription.signal } + ); + const result = await connection.client.workspace.sendMessage({ + workspaceId, + message: "list 3 programming languages", + options: { model: "anthropic:claude-sonnet-5", agentId: "exec" }, + }); + expect(result.success).toBe(true); + state = createTranscriptState(); + for await (const event of live) { + state = applyChatEvent(state, event); + if (event.type === "stream-error") throw new Error(event.error); + if (event.type === "stream-end") break; + } + const assistant = state.messages.find((message) => message.role === "assistant"); + expect(assistant?.parts.some((part) => part.type === "text" && part.text.length > 0)).toBe( + true + ); + subscription.abort(); + connection.close(); + + const reconnected = await connection.reconnect(); + try { + let replay = createTranscriptState(); + const replayEvents = await reconnected.client.workspace.onChat({ + workspaceId, + mode: { type: "full" }, + }); + for await (const event of replayEvents) { + replay = applyChatEvent(replay, event); + if (replay.caughtUp) break; + } + expect(replay.messages.map((message) => message.id)).toEqual( + state.messages.map((message) => message.id) + ); + expect(replay.messages.find((message) => message.id === assistant?.id)?.parts).toEqual( + assistant?.parts + ); + expect((await reconnected.client.workspace.remove({ workspaceId })).success).toBe(true); + workspaceId = undefined; + } finally { + reconnected.close(); + } + } finally { + subscription.abort(); + if (workspaceId) { + const cleanup = await connection.reconnect(); + try { + await cleanup.client.workspace.remove({ workspaceId }); + } finally { + cleanup.close(); + } + } + connection.close(); + } + }, + 30_000 +); diff --git a/packages/mobile/src/api.test.ts b/packages/mobile/src/api.test.ts new file mode 100644 index 00000000000..6c2d343ba65 --- /dev/null +++ b/packages/mobile/src/api.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { ORPCError, os } from "@orpc/server"; +import { RPCHandler } from "@orpc/server/node"; +import { createServer } from "node:http"; +import { z } from "zod"; +import { once } from "node:events"; +import assert from "node:assert/strict"; +import { connect } from "./api"; +import { connect as connectPreview } from "./connection.web"; +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 mutations = 0; + 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 router = { + 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* ({ input, signal }): AsyncGenerator { + subscriptions.push({ aborted: () => signal?.aborted === true }); + onSubscribe(); + yield Promise.resolve({ + type: "message", + id: "one", + role: "user", + 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 httpServer = createServer((request, response) => { + requests.push({ url: request.url, authorization: request.headers.authorization }); + handler + .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(); + }); + }); + 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, + requests, + subscriptions, + subscribed, + calls: () => calls, + mutations: () => mutations, + [Symbol.asyncDispose]: async () => { + httpServer.closeAllConnections(); + await new Promise((resolve, reject) => { + if (!httpServer.listening) resolve(); + else httpServer.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +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(); + 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 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); + 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"]); + // 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}`); + } + + connection.close(); + connection.close(); + await expectFailure(connection.client.workspace.list(), "Connection closed."); + expect(server.calls()).toBe(1); + }); + + 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 } + ); + 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); + 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); + }); + + 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.calls()).toBe(0); + }); + + 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 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 }); + while (server.calls() === 0) await new Promise((resolve) => setTimeout(resolve, 5)); + controller.abort("secret cancellation reason"); + await expectFailure(pending, "Connection cancelled."); + expect(server.calls()).toBe(1); + }); + + test("an already aborted signal never sends a request", 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.requests).toHaveLength(0); + }); + + 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 { + await expectFailure(connect(server.endpoint, server.token), "Connection timed out."); + } finally { + timer.mockRestore(); + } + }); + + 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 any request 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/api.ts b/packages/mobile/src/api.ts new file mode 100644 index 00000000000..f526c09ba57 --- /dev/null +++ b/packages/mobile/src/api.ts @@ -0,0 +1,102 @@ +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/fetch"; +import type * as schemas from "../../../src/common/orpc/schemas/api"; +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). +type SchemaClient = T extends { + input: infer I extends AnySchema; + output: infer O extends AnySchema; +} + ? Client, InferSchemaOutput, Error> + : { [K in keyof T]: SchemaClient }; +export type MobileClient = SchemaClient< + Pick< + typeof schemas, + "projects" | "workspace" | "providers" | "agents" | "config" | "policy" | "server" + > +>; +export interface MobileConnection { + client: MobileClient; + endpoint: string; + close: () => void; + reconnect: (options?: { signal?: AbortSignal }) => Promise; +} + +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") + ); +} + +/** + * 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, + 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 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 timedOut = false; + const cancel = () => probe.abort(); + options.signal?.addEventListener("abort", cancel, { once: true }); + const timeout = setTimeout(() => { + timedOut = true; + probe.abort(); + }, CONNECT_TIMEOUT_MS); + try { + // Reachability alone proves nothing; an authenticated RPC must succeed. + await client.workspace.list(undefined, { signal: probe.signal }); + } catch (cause) { + if (options.signal?.aborted) throw new Error("Connection cancelled."); + if (timedOut) throw new Error("Connection timed out."); + 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/components/ContextUsage.tsx b/packages/mobile/src/components/ContextUsage.tsx new file mode 100644 index 00000000000..3001ae810a1 --- /dev/null +++ b/packages/mobile/src/components/ContextUsage.tsx @@ -0,0 +1,62 @@ +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/Controls.tsx b/packages/mobile/src/components/Controls.tsx new file mode 100644 index 00000000000..555dec7c740 --- /dev/null +++ b/packages/mobile/src/components/Controls.tsx @@ -0,0 +1,395 @@ +import { forwardRef } from "react"; +import type { ReactNode } from "react"; +import { + ActivityIndicator, + Modal, + Platform, + 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, 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: { + label: string; + icon: LucideIcon; + onPress: () => void; + disabled?: boolean; + color?: string; +}) { + const Icon = props.icon; + return ( + [styles.iconButton, pressed && styles.pressed]} + > + + + ); +} + +export function Button(props: { + children: string; + onPress: () => void; + 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, + props.destructive && styles.destructive, + props.disabled && styles.secondary, + pressed && styles.pressed, + ]} + > + {props.busy ? ( + + ) : Icon ? ( + + ) : null} + {props.children} + + ); +} + +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; + 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} + + + {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; + footer?: ReactNode; + variant?: "picker"; + action?: ReactNode; + dismissDisabled?: boolean; + onBack?: () => void; +}) { + function dismiss() { + 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} + + {props.footer && {props.footer}} + + + + + ); +} + +const styles = StyleSheet.create({ + iconButton: { + minWidth: 44, + minHeight: 44, + alignItems: "center", + justifyContent: "center", + borderRadius: radii.control, + }, + pressed: { opacity: 0.7 }, + button: { + minHeight: 50, + borderRadius: radii.control, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.sm, + backgroundColor: colors.accent, + }, + secondary: { backgroundColor: colors.elevated }, + 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, + minWidth: 0, + flex: 1, + minHeight: 50, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + notice: { + padding: spacing.lg, + gap: spacing.md, + backgroundColor: colors.panel, + borderRadius: radii.control, + }, + loading: { + padding: spacing.xxl, + gap: spacing.md, + alignItems: "center", + justifyContent: "center", + }, + header: { + flexDirection: "row", + gap: spacing.sm, + alignItems: "center", + paddingHorizontal: spacing.lg, + minHeight: 56, + borderBottomColor: colors.border, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + headerTitle: { ...typography.header, color: colors.bright, textAlign: "center" }, + 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", + }, + 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, + borderTopColor: colors.border, + padding: spacing.xl, + gap: spacing.md, + }, +}); diff --git a/packages/mobile/src/components/DraftExtras.tsx b/packages/mobile/src/components/DraftExtras.tsx new file mode 100644 index 00000000000..b08546ad8b1 --- /dev/null +++ b/packages/mobile/src/components/DraftExtras.tsx @@ -0,0 +1,54 @@ +import type { SetStateAction } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { X } from "lucide-react-native"; +import type { ChatDraft } from "../draft"; +import { IconButton } from "./Controls"; +import { layout, spacing } from "../theme"; + +export function DraftExtras(props: { + draft: ChatDraft; + onChange: (update: SetStateAction) => void; +}) { + if (!props.draft.fileParts.length && !props.draft.reviews.length) return null; + return ( + + {props.draft.fileParts.map((part, index) => { + const name = part.filename ?? part.mediaType; + return ( + + + {name} + + + props.onChange((current) => ({ + ...current, + fileParts: current.fileParts.filter((_, item) => item !== index), + })) + } + /> + + ); + })} + {props.draft.reviews.length > 0 && ( + + + {props.draft.reviews.length} review{" "} + {props.draft.reviews.length === 1 ? "note" : "notes"} + + props.onChange((current) => ({ ...current, reviews: [] }))} + /> + + )} + + ); +} 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/Markdown.tsx b/packages/mobile/src/components/Markdown.tsx new file mode 100644 index 00000000000..e5ce56f23ff --- /dev/null +++ b/packages/mobile/src/components/Markdown.tsx @@ -0,0 +1,166 @@ +import type { ReactNode } from "react"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +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(/(`[^`\n]+`|\*\*[^*\n]+\*\*)/g).map((part, index) => { + if (/^`[^`\n]+`$/.test(part)) + return ( + + {part.slice(1, -1)} + + ); + if (/^\*\*[^*\n]+\*\*$/.test(part)) + return ( + + {part.slice(2, -2)} + + ); + return part; + }); +} + +// Repo/model text is untrusted: all content remains escaped native Text, never HTML. +export function Markdown(props: { text: string }) { + return ( + + {blocks(props.text).map((block, index) => { + if (block.type === "code") + return ( + + {block.language !== "" && {block.language}} + + + {block.text} + + + + ); + if (block.type === "list") + return ( + + {block.items.map((item, itemIndex) => ( + + {item.marker} + + {inline(item.text)} + + + ))} + + ); + return ( + + {inline(block.text)} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + 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, + }, + 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 new file mode 100644 index 00000000000..38cf93661eb --- /dev/null +++ b/packages/mobile/src/components/Message.tsx @@ -0,0 +1,457 @@ +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, +} 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"; +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: { + message: MuxMessage; + canAnswer: boolean; + streaming?: boolean; + 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 ( + + {props.message.role === "system" && System} + {/* 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 ; + case "reasoning": + return ; + case "dynamic-tool": + return ( + + ); + case "file": + return ( + + + {part.filename ?? part.mediaType} · attachment + + ); + } + })} + {props.message.role === "assistant" && + !props.streaming && + (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 Reasoning(props: { text: string; streaming: boolean }) { + const [expanded, setExpanded] = useState(false); + return ( + + setExpanded(!expanded)} + style={styles.actionRow} + > + + Reasoning + {expanded ? ( + + ) : ( + + )} + + {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); + } +} + +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" + : "Done"; + } + if (!streaming) return interrupted ? "Interrupted" : "No result"; + if (part.toolName === "ask_user_question") return "Needs input"; + // 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; + +function ToolValue(props: { label: string; value: unknown }) { + const text = + typeof props.value === "string" + ? props.value + : (JSON.stringify(props.value, null, 2) ?? "No output"); + return ( + + {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: ToolPart; + nested?: boolean; + streaming: boolean; + interrupted: boolean; + canAnswer: boolean; + onAnswer: (toolCallId: string, answers: Record) => Promise; +}) { + const [inspecting, setInspecting] = useState(false); + const questionInput = + !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, 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 && + (!("executionStartedAt" in props.part) || props.part.executionStartedAt == null); + // The bridge schema has one flat child level and excludes interactive questions. + const nestedCalls = + !props.nested && "type" in props.part ? getNestedCallsForDisplay(props.part) : undefined; + return ( + + setInspecting(true)} + style={styles.actionRow} + > + + + + {name} + {hint && {hint}} + + + + {status} + + + + {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."} + + )} + + )} + {questionInput && ( + props.onAnswer(props.part.toolCallId, answers)} + /> + )} + {nestedCalls && nestedCalls.length > 0 && ( + + {nestedCalls.map((call) => ( + + ))} + + )} + + ); +} + +interface QuestionDraft { + selected: Array; + otherText: string; +} + +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( + 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 (disabled || !complete) 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) => { + 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}} + + + ); +} + +const styles = StyleSheet.create({ + message: { gap: spacing.md, paddingVertical: spacing.lg, alignSelf: "stretch" }, + secondary: { ...typography.footnote, color: colors.muted }, + user: { + backgroundColor: colors.user, + borderRadius: radii.card, + paddingVertical: spacing.md, + paddingHorizontal: spacing.lg, + marginVertical: spacing.sm, + alignSelf: "flex-end", + maxWidth: "94%", + }, + interrupted: { flexDirection: "row", alignItems: "center", gap: spacing.sm }, + actionRow: { flexDirection: "row", alignItems: "center", gap: spacing.sm, minHeight: 44 }, + reasoningBody: { + marginLeft: spacing.sm, + paddingLeft: spacing.lg, + paddingVertical: spacing.sm, + 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 }, + 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, + backgroundColor: colors.panel, + padding: spacing.lg, + }, +}); diff --git a/packages/mobile/src/components/ToolIcon.tsx b/packages/mobile/src/components/ToolIcon.tsx new file mode 100644 index 00000000000..e8a07f884c0 --- /dev/null +++ b/packages/mobile/src/components/ToolIcon.tsx @@ -0,0 +1,81 @@ +import { + Activity, + ArrowDownUp, + Bell, + BookOpen, + Brain, + BrainCircuit, + CircleCheck, + Database, + FileText, + GitCommit, + Globe, + GraduationCap, + Hand, + History, + Keyboard, + Layers, + LayoutGrid, + Lightbulb, + List, + MessageCircleQuestion, + MessageSquareMore, + Monitor, + MousePointerClick, + Move, + Paperclip, + Pencil, + RotateCcw, + ScanEye, + Search, + Sparkles, + Square, + Target, + Trash2, + Wrench, +} from "lucide-react-native"; +import type { LucideIcon } from "lucide-react-native"; +import { getToolIconName, type ToolIconName } from "../../../../src/common/constants/toolIcons"; +import { colors } from "../theme"; + +const toolIcons: Record = { + Activity, + ArrowDownUp, + Bell, + BookOpen, + Brain, + BrainCircuit, + CircleCheck, + Database, + FileText, + GitCommit, + Globe, + GraduationCap, + Hand, + History, + Keyboard, + Layers, + LayoutGrid, + Lightbulb, + List, + MessageCircleQuestion, + MessageSquareMore, + Monitor, + MousePointerClick, + Move, + Paperclip, + Pencil, + RotateCcw, + ScanEye, + Search, + Sparkles, + Square, + Target, + Trash2, + Wrench, +}; + +export function ToolIcon(props: { toolName: string }) { + const Icon = toolIcons[getToolIconName(props.toolName)]; + return ; +} 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/composerKeyboard.ts b/packages/mobile/src/composerKeyboard.ts new file mode 100644 index 00000000000..a5f49c9c279 --- /dev/null +++ b/packages/mobile/src/composerKeyboard.ts @@ -0,0 +1,29 @@ +import { KEYBINDS, matchesKeybind } from "../../../src/browser/utils/ui/keybinds"; + +/** RN Web forwards a DOM keydown; native TextInput events do not expose these fields. */ +export function getWebComposerKeyAction(event: unknown): "send" | "stop" | undefined { + if ( + typeof window === "undefined" || + typeof window.KeyboardEvent !== "function" || + !(event instanceof window.KeyboardEvent) + ) + return; + if ( + event.defaultPrevented || + event.isComposing || + event.keyCode === 229 || + event.repeat || + !(event.target instanceof window.HTMLTextAreaElement) || + event.target !== document.activeElement || + // RN Web marks modal content before its entrance animation assigns role="dialog". + document.querySelector('[aria-modal="true"]') !== null + ) + return; + if (matchesKeybind(event, KEYBINDS.INTERRUPT_STREAM_NORMAL)) return "stop"; + if ( + matchesKeybind(event, KEYBINDS.SEND_MESSAGE_AFTER_TURN) || + (matchesKeybind(event, KEYBINDS.SEND_MESSAGE) && + window.matchMedia("(min-width: 769px) and (pointer: fine)").matches) + ) + return "send"; +} diff --git a/packages/mobile/src/connection.ts b/packages/mobile/src/connection.ts new file mode 100644 index 00000000000..d45eb88943a --- /dev/null +++ b/packages/mobile/src/connection.ts @@ -0,0 +1,2 @@ +export { connect } from "./api"; +export type { MobileClient } from "./api"; diff --git a/packages/mobile/src/connection.web.ts b/packages/mobile/src/connection.web.ts new file mode 100644 index 00000000000..ba70277685c --- /dev/null +++ b/packages/mobile/src/connection.web.ts @@ -0,0 +1,32 @@ +import { connect as connectDirect } from "./api"; +import { normalizeEndpoint } from "./endpoint"; +export type { MobileClient } from "./api"; + +export async function connect(...args: Parameters) { + const [endpoint, token, options] = args; + const normalized = normalizeEndpoint(endpoint); + // The fixed-target preview proxy bridges browser Origin checks without changing + // the production server's CSRF protection or forwarding to user-controlled URLs. + const response = await fetch("/__xum", { signal: options?.signal }); + if (!response.ok) + throw new Error( + "Start the mobile web preview proxy with XUM_MOBILE_ENDPOINT set to your server." + ); + const config: unknown = await response.json(); + if ( + typeof config !== "object" || + config === null || + !("endpoint" in config) || + config.endpoint !== normalized + ) { + throw new Error( + "This preview can only connect to its configured XUM_MOBILE_ENDPOINT. Native builds connect directly." + ); + } + const connection = await connectDirect(`${window.location.origin}/__xum`, token, options); + return { + ...connection, + endpoint: normalized, + reconnect: (options?: { signal?: AbortSignal }) => connect(normalized, token, options), + }; +} diff --git a/packages/mobile/src/contextUsage.test.ts b/packages/mobile/src/contextUsage.test.ts new file mode 100644 index 00000000000..0bc400753fe --- /dev/null +++ b/packages/mobile/src/contextUsage.test.ts @@ -0,0 +1,339 @@ +import { expect, test } from "bun:test"; +import type { ChatSettings } from "./settings"; +import type { MuxMessage, WorkspaceChatMessage } from "./transcript"; +import { applyChatEvent, createTranscriptState } from "./transcript"; +import { getContextUsage, getContextMeterData } 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); +}); + +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 + ); +}); + +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 }], + }, + }; + let options: ChatSettings = { + 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, contextWindowTokens: 1_000_000 }); + const current = () => + getContextMeterData(state.messages, options, providers, state.streamingMessageId); + // Before fresh usage arrives, prior requests do not populate the active attempt. + expect(current().totalTokens).toBe(0); + 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); + options = { ...options, providerOptions: { anthropic: { disableBetaFeatures: true } } }; + providers.anthropic.models[0].contextWindowTokens = 100_000; + expect(current().totalPercentage).toBe(20); + // The wire pin also survives an active-stream replay after preferences changed elsewhere. + state = applyChatEvent(createTranscriptState(), { + ...start, + model: activeModel, + replay: true, + contextWindowTokens: 1_000_000, + }); + state = applyChatEvent(state, { ...delta, usage: contextUsage }); + 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); +}); + +test("backend-confirmed unknown active capacity does not borrow a live configured limit", () => { + const state = applyChatEvent(createTranscriptState(), { + ...start, + model: "openai:gpt-4o", + contextWindowTokens: null, + }); + const withUsage = applyChatEvent(state, delta); + expect( + getContextMeterData( + withUsage.messages, + { model: "openai:gpt-4o", agentId: "exec" }, + undefined, + withUsage.streamingMessageId + ).maxTokens + ).toBeUndefined(); +}); + +test("fallback resets only active attempt usage until fresh usage arrives, matching replay", () => { + const sourceModel = "anthropic:claude-sonnet-4-20250514"; + const nextModel = "openai:gpt-4o"; + const requestUsage = { inputTokens: 100_000, outputTokens: 0, totalTokens: 100_000 }; + const providerMetadata = { anthropic: { cacheCreationInputTokens: 10 } }; + const history = applyChatEvent(createTranscriptState(), { type: "message", ...row }); + let state = applyChatEvent(history, { + ...start, + model: sourceModel, + contextWindowTokens: 1_000_000, + }); + state = applyChatEvent(state, { + type: "stream-delta", + workspaceId: "w", + messageId: "b", + delta: "partial answer", + tokens: 2, + timestamp: 2, + }); + state = applyChatEvent(state, { + type: "tool-call-start", + workspaceId: "w", + messageId: "b", + toolCallId: "tool", + toolName: "bash", + tokens: 1, + args: {}, + timestamp: 3, + }); + const active = state.messages.find((message) => message.id === "b")!; + state = applyChatEvent(state, { + type: "message", + ...active, + metadata: { + ...active.metadata, + usage: requestUsage, + contextUsage: requestUsage, + providerMetadata, + contextProviderMetadata: providerMetadata, + routeProvider: "coder", + routedThroughGateway: true, + thinkingLevel: "high", + }, + }); + const before = state.messages.find((message) => message.id === "b")!; + const options: ChatSettings = { model: nextModel, agentId: "exec" }; + const current = () => + getContextMeterData(state.messages, options, undefined, state.streamingMessageId); + expect(current().totalTokens).toBeGreaterThan(0); + const event: Extract = { + type: "stream-metadata", + workspaceId: "w", + messageId: "b", + metadata: { + model: nextModel, + metadataModel: nextModel, + contextWindowTokens: 400_000, + routedThroughGateway: false, + routeProvider: null, + modelFallback: { requestedModel: sourceModel, refusedModels: [sourceModel] }, + }, + }; + expect(applyChatEvent(state, { ...event, messageId: "old" })).toBe(state); + expect(applyChatEvent(state, { ...event, workspaceId: "other" })).toBe(state); + state = applyChatEvent(state, event); + const after = state.messages.find((message) => message.id === "b")!; + expect(after.parts).toBe(before.parts); + expect(after.metadata?.historySequence).toBe(before.metadata?.historySequence); + expect(state.messages[0]).toBe(history.messages[0]); + expect(state.streamingMessageId).toBe("b"); + expect(state.streaming).toBe(true); + for (const key of [ + "usage", + "contextUsage", + "providerMetadata", + "contextProviderMetadata", + "thinkingLevel", + "routeProvider", + ] as const) { + expect(after.metadata?.[key]).toBeUndefined(); + } + expect(after.metadata?.modelFallback).toEqual(event.metadata.modelFallback); + expect(after.metadata?.contextWindowTokens).toBe(400_000); + expect(current().totalTokens).toBe(0); + expect(current().segments).toEqual([]); + // Historical usage must not fill the empty new attempt, live or after reconnect. + const replay = applyChatEvent(history, { + ...start, + model: nextModel, + contextWindowTokens: 400_000, + replay: true, + }); + expect( + getContextMeterData(replay.messages, options, undefined, replay.streamingMessageId) + ).toEqual(current()); + state = applyChatEvent(state, { ...delta, usage: requestUsage }); + expect(current().totalPercentage).toBe(25); + state = applyChatEvent(state, { + ...event, + metadata: { + model: "local:unknown", + metadataModel: "local:unknown", + contextWindowTokens: null, + routedThroughGateway: false, + routeProvider: null, + }, + }); + expect(current().totalTokens).toBe(0); + expect( + state.messages.find((message) => message.id === "b")?.metadata?.modelFallback + ).toBeUndefined(); + state = applyChatEvent(state, { ...delta, usage: requestUsage }); + expect(current().maxTokens).toBeUndefined(); + expect(current().totalTokens).toBe(100_000); +}); diff --git a/packages/mobile/src/contextUsage.ts b/packages/mobile/src/contextUsage.ts new file mode 100644 index 00000000000..537289b1bfc --- /dev/null +++ b/packages/mobile/src/contextUsage.ts @@ -0,0 +1,66 @@ +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"; + +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; +} + +export function getContextMeterData( + messages: MuxMessage[], + options: ChatSettings | null, + providers?: SettingsData["providers"], + streamingMessageId?: string | null +) { + // A picker change targets the next request, not the turn still using this context. + const activeMessage = streamingMessageId + ? messages.find((message) => message.id === streamingMessageId) + : undefined; + const activeMetadata = activeMessage?.metadata; + const model = activeMetadata?.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( + // Never repopulate a reset attempt with an older request's historical usage. + getContextUsage(activeMessage ? [activeMessage] : streamingMessageId ? [] : messages, model), + model, + use1M, + false, + providers, + activeMetadata?.contextWindowTokens + ); +} diff --git a/packages/mobile/src/credentials.ts b/packages/mobile/src/credentials.ts new file mode 100644 index 00000000000..9e074e63dcf --- /dev/null +++ b/packages/mobile/src/credentials.ts @@ -0,0 +1,39 @@ +import * as SecureStore from "expo-secure-store"; + +export interface Credentials { + endpoint: string; + token: string; +} + +const KEY = "xum.mobile.connection"; + +export async function loadCredentials(): Promise { + const stored = await SecureStore.getItemAsync(KEY); + if (!stored) return null; + try { + const value: unknown = JSON.parse(stored); + if ( + typeof value === "object" && + value !== null && + "endpoint" in value && + typeof value.endpoint === "string" && + "token" in value && + typeof value.token === "string" + ) + return { endpoint: value.endpoint, token: value.token }; + } catch { + // Corrupted device storage must not prevent connecting to a server again. + } + await clearCredentials(); + return null; +} + +export async function saveCredentials(value: Credentials): Promise { + await SecureStore.setItemAsync(KEY, JSON.stringify(value), { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + }); +} + +export async function clearCredentials(): Promise { + await SecureStore.deleteItemAsync(KEY); +} diff --git a/packages/mobile/src/credentials.web.ts b/packages/mobile/src/credentials.web.ts new file mode 100644 index 00000000000..784501a89d4 --- /dev/null +++ b/packages/mobile/src/credentials.web.ts @@ -0,0 +1,8 @@ +import type { Credentials } from "./credentials"; + +// Browser previews have no Keychain. Never persist the server token in web storage. +export async function loadCredentials(): Promise { + return null; +} +export async function saveCredentials(_value: Credentials): Promise {} +export async function clearCredentials(): Promise {} 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/draft.ts b/packages/mobile/src/draft.ts new file mode 100644 index 00000000000..c0dfcdbd513 --- /dev/null +++ b/packages/mobile/src/draft.ts @@ -0,0 +1,6 @@ +import type { UserMessageContent } from "../../../src/common/types/message"; +import type { WorkspaceChatMessage } from "../../../src/common/orpc/types"; + +export type ChatDraft = Required; +export type RestoredInput = Extract; +export const EMPTY_DRAFT: ChatDraft = { text: "", fileParts: [], reviews: [] }; diff --git a/packages/mobile/src/endpoint.test.ts b/packages/mobile/src/endpoint.test.ts new file mode 100644 index 00000000000..489e04e2de8 --- /dev/null +++ b/packages/mobile/src/endpoint.test.ts @@ -0,0 +1,88 @@ +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://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", + "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", "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 new file mode 100644 index 00000000000..ae99e474d54 --- /dev/null +++ b/packages/mobile/src/endpoint.ts @@ -0,0 +1,38 @@ +function isLoopbackHost(hostname: string): boolean { + if (hostname === "localhost" || hostname === "[::1]") return true; + // 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)) { + return false; + } + return octets[0] === 127; +} + +/** 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:" && !isLoopbackHost(url.hostname)) { + throw new Error("Remote servers require HTTPS. HTTP is only allowed for loopback addresses."); + } + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +} + +/** 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/nativeTestPlatform.ts b/packages/mobile/src/nativeTestPlatform.ts new file mode 100644 index 00000000000..bd224a1004a --- /dev/null +++ b/packages/mobile/src/nativeTestPlatform.ts @@ -0,0 +1,32 @@ +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."); +} + +// 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/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 new file mode 100644 index 00000000000..5b3955d022c --- /dev/null +++ b/packages/mobile/src/polyfills.native.ts @@ -0,0 +1,18 @@ +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 }); +} diff --git a/packages/mobile/src/polyfills.ts b/packages/mobile/src/polyfills.ts new file mode 100644 index 00000000000..ea1f3bce3a8 --- /dev/null +++ b/packages/mobile/src/polyfills.ts @@ -0,0 +1,2 @@ +// Browsers already provide the stream primitives used by oRPC. +export {}; diff --git a/packages/mobile/src/screens/ChangesScreen.tsx b/packages/mobile/src/screens/ChangesScreen.tsx new file mode 100644 index 00000000000..abf8d66c86b --- /dev/null +++ b/packages/mobile/src/screens/ChangesScreen.tsx @@ -0,0 +1,202 @@ +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"; + +export function ChangesScreen(props: { + client: MobileClient; + workspaceId: string; + signal: AbortSignal; + onReconnect: () => Promise; + onBack: () => void; +}) { + const [projects, setProjects] = useState(null); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + function retry() { + setGeneration((value) => value + 1); + } + useEffect(() => { + const controller = linkedAbortController(props.signal); + setProjects(null); + setError(null); + if (controller.signal.aborted) { + setError("Reconnect to load changes."); + return; + } + props.client.workspace + .getProjectDiffs({ workspaceId: props.workspaceId }, { signal: controller.signal }) + .then((results) => { + if (!controller.signal.aborted) setProjects(results); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) + setError(cause instanceof Error ? cause.message : "Could not load changes."); + }); + return () => controller.abort(); + }, [props.client, props.workspaceId, props.signal, generation]); + const clean = + projects != null && + projects.length > 0 && + projects.every( + (project) => project.success && !project.data.truncated && project.data.diff === "" + ); + return ( + +
} + /> + + {error && {error}} + {projects === null && !error && } + {projects?.length === 0 && ( + This workspace has no Git repositories. + )} + {clean && ( + + + No uncommitted changes + + Tracked files match the latest commit. + + + )} + {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. + + )} + + + ); +} + +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"); + // 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" + ? headers + .find((line) => line.startsWith("--- ")) + ?.slice(4) + .replace(/^a\//, "") + : newPath?.replace(/^b\//, "")) ?? lines[0].replace(/^diff --git /, ""); + 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 ( + + + + + {filename} + + +{additions} + −{deletions} + + + + {content.map((line, lineIndex) => ( + + {line || " "} + + ))} + + + + ); + })} + + ); +} + +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/ConnectScreen.tsx b/packages/mobile/src/screens/ConnectScreen.tsx new file mode 100644 index 00000000000..cd55bb84d6a --- /dev/null +++ b/packages/mobile/src/screens/ConnectScreen.tsx @@ -0,0 +1,190 @@ +import { useEffect, useRef, useState } from "react"; +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>; + +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); + const tokenInput = useRef(null); + const [showToken, setShowToken] = useState(false); + let insecure = false; + try { + insecure = isInsecureEndpoint(endpoint.trim()); + } catch { + /* A partially entered URL is validated on connect, not while typing. */ + } + + 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. + + Connect to Xum + Enter the address of your Xum server. + + {loading ? ( + + ) : ( + <> + + 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 && ( + + HTTP is not encrypted. Use loopback HTTP only for development on this device. + Connections to other devices require HTTPS. + + )} + {error && {error}} + + + )} + {/* The web build previews native UX; storage differences belong in developer docs. */} + + + + Your token authenticates this app with your Xum server. + + + + + + ); +} + +const styles = StyleSheet.create({ + page: { flexGrow: 1, padding: spacing.xl, paddingTop: spacing.xxxl }, + form: { gap: spacing.xxl, maxWidth: 480, width: "100%", alignSelf: "center" }, + wordmark: { + color: colors.bright, + fontSize: 28, + lineHeight: 36, + fontWeight: "700", + letterSpacing: -1, + marginBottom: spacing.lg, + }, + fields: { gap: spacing.xl }, +}); diff --git a/packages/mobile/src/screens/ConversationScreen.tsx b/packages/mobile/src/screens/ConversationScreen.tsx new file mode 100644 index 00000000000..3a2f4d7067f --- /dev/null +++ b/packages/mobile/src/screens/ConversationScreen.tsx @@ -0,0 +1,747 @@ +import { useEffect, useRef, useState } from "react"; +import type { SetStateAction } from "react"; +import type { TextInputKeyPressEvent } from "react-native"; +import { FlatList, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + ClipboardList, + Settings, + GitCompareArrows, + ChevronLeft, + Square, +} from "lucide-react-native"; +import type { MobileClient } from "../api"; +import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; +import { resolvePersistedAgentId } from "../../../../src/common/utils/agentIds"; +import { prepareUserMessageForSend } from "../../../../src/common/types/message"; +import type { MuxMessage } from "../../../../src/common/types/message"; +import { Button, IconButton, Loading, Notice } from "../components/Controls"; +import { KeyboardAvoidingView } from "../components/Keyboard"; +import { DraftExtras } from "../components/DraftExtras"; +import { EMPTY_DRAFT } from "../draft"; +import type { ChatDraft } from "../draft"; +import { Message } from "../components/Message"; +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, + getPolicyStateBlockReason, + modelName, + resolveSettings, +} from "../settings"; +import type { ChatSettings } from "../settings"; +import { ModelSettings } from "./ModelSettings"; +import { colors, fontFamily, layout, radii, spacing, typography } from "../theme"; +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. +// 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; + onReconnect: () => Promise; + onBack: () => void; + selection: ChatSettings | null; + onSelectionChange: (value: ChatSettings) => void; + draft: ChatDraft; + onDraftChange: (value: SetStateAction) => void; + onChanges: () => void; + onSettings: () => void; +}) { + const { transcript, settings, error, settingsError, loadOlder, loadingOlder, historyError } = + useConversation(props.client, props.workspace.id, props.signal, (restored) => { + props.onDraftChange((current) => ({ + text: [current.text, restored.text].filter(Boolean).join("\n\n"), + fileParts: [...current.fileParts, ...(restored.fileParts ?? [])], + reviews: [...current.reviews, ...(restored.reviews ?? [])], + })); + }); + const draft = props.draft; + const hasDraft = Boolean(draft.text.trim() || draft.fileParts.length || draft.reviews.length); + const setDraft = props.onDraftChange; + const [inputFocused, setInputFocused] = useState(false); + const [inputHeight, setInputHeight] = useState(44); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + const [resumeMessageId, setResumeMessageId] = useState(null); + const [startedResumeMessageId, setStartedResumeMessageId] = useState(null); + const [showSettings, setShowSettings] = useState<"model" | "agent" | null>(null); + const [atBottom, setAtBottom] = useState(true); + const [composerHeight, setComposerHeight] = useState(100); + 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 = linkedAbortController(props.signal); + controller.current = abort; + pending.current = false; + setStartedResumeMessageId(null); + 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(() => { + // 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); + 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( + props.workspace, + settings, + props.selection?.agentId ?? agentId, + props.selection + ) + : null; + const context = getContextMeterData( + transcript.messages, + options, + settings?.providers, + transcript.streamingMessageId + ); + // 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 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 && !requestBlockReason && !transcriptOnly; + const latestSettings = useRef({ options, requestBlockReason, transcriptOnly }); + useEffect(() => { + 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, + // settings and global policy blocks still apply; recovery needs a routable model too. + const canAnswer = + settingsReady && !transcriptOnly && (running ? !getPolicyStateBlockReason(settings) : canAct); + const expanded = inputFocused || hasDraft || 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.metadata.userStopped + ? lastMessage + : undefined; + // The saved tool result survives remount/reconnect even when the answer RPC's + // local recovery state does not. Only the latest interrupted turn is eligible. + const resumeTargetId = + lastMessage?.role === "assistant" && + lastMessage.metadata?.partial && + !lastMessage.metadata.userStopped && + (resumeMessageId === lastMessage.id || + lastMessage.parts.some( + (part) => + part.type === "dynamic-tool" && + part.toolName === "ask_user_question" && + part.state === "output-available" + )) + ? lastMessage.id + : null; + const canResume = + canAct && !running && resumeTargetId !== null && resumeTargetId !== startedResumeMessageId; + + async function handleComposerKeyPress(event: TextInputKeyPressEvent) { + if (actionDisabled || pending.current) return; + const action = getWebComposerKeyAction(event.nativeEvent); + // Modified Enter is only an idle send here: mobile does not offer turn queueing. + if (!action || (action === "send" ? running : !running)) return; + event.preventDefault(); + if (action === "send") await send(); + else await interrupt(); + } + + async function send() { + if (!canAct || !options?.model || !hasDraft || pending.current || running) return; + pending.current = true; + setBusy(true); + setActionError(null); + // All draft updates are immutable: only clear this sent version, never newer + // typing or another queue restoration that arrives while the request is pending. + const sent = draft; + const signal = controller.current.signal; + try { + const { finalText, metadata } = prepareUserMessageForSend(sent); + const result = await props.client.workspace.sendMessage( + { + workspaceId: props.workspace.id, + message: finalText, + options: { + ...options, + ...(sent.fileParts.length ? { fileParts: sent.fileParts } : {}), + ...(metadata ? { muxMetadata: metadata } : {}), + }, + }, + { signal } + ); + if (signal.aborted) return; + if (!result.success) + throw new Error( + typeof result.error === "string" ? result.error : JSON.stringify(result.error) + ); + setDraft((current) => (current === sent ? EMPTY_DRAFT : current)); + // Content-size events resize cleared input without shrinking a newer draft. + 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 { + if (controller.current.signal === signal) { + 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, + // Match desktop User Stop: owed monitor output must not restart the stopped turn. + options: { retireBashMonitorAttention: true, disableAutoRetry: true }, + }, + { 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 { + if (controller.current.signal === signal) { + pending.current = false; + if (!signal.aborted) setBusy(false); + } + } + } + + 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 || + !latest.metadata?.partial + ) + return; + 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 || requestBlockReason || transcriptOnly) return; + 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) + ); + if (result.data.started) { + setResumeMessageId(null); + // The durable partial may replay before stream-start arrives. Do not offer + // a second start until stream activity or a fresh connection reconciles it. + if (!latestTranscript.current.streaming) setStartedResumeMessageId(messageId); + } else setActionError("Answers saved. The agent is busy; try resuming again."); + } 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 || !resumeTargetId || pending.current) return; + pending.current = true; + setBusy(true); + setActionError(null); + const signal = controller.current.signal; + try { + await resumeAnsweredQuestion(resumeTargetId, 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."); + if (!canAnswer) + throw new Error(requestBlockReason ?? settingsError ?? "Wait for settings before answering."); + 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 ( + + + + + + + + {props.workspace.title ?? props.workspace.name} + + + {props.workspace.kind === "scratch" ? "Scratch chat" : props.workspace.projectName} ·{" "} + {props.serverLabel} + + + + + + + + message.id} + contentContainerStyle={styles.messages} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + ListHeaderComponent={ + transcript.hasOlderHistory ? ( + + {historyError && {historyError}} + + + ) : null + } + 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 && !loadError ? ( + + ) : loadError ? null : ( + + What’s on your mind? + + Ask a question, plan a change, or let an agent take it from here. + + + ) + } + ListFooterComponent={ + + {loadError && {loadError}} + {transcript.error && {transcript.error}} + {canResume && ( + + )} + {running && ( + Agent is working… + )} + + } + /> + {!atBottom && ( + + list.current?.scrollToEnd({ animated: true })} + /> + + )} + {transcriptOnly ? ( + setComposerHeight(event.nativeEvent.layout.height)} + > + + This workspace's worktree is no longer available. This is a read-only chat transcript. + + {actionError && {actionError}} + {/* A missing checkout blocks new work, not stopping a stream that is already live. */} + {running && ( + + )} + + ) : ( + setComposerHeight(event.nativeEvent.layout.height)} + > + {requestBlockReason && ( + + {requestBlockReason} + + )} + {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 && + !transcriptOnly && + (!modeLocked || showSettings === "model") && ( + setShowSettings(null)} + onChange={props.onSelectionChange} + /> + )} + + ); +} + +const styles = StyleSheet.create({ + header: { + minHeight: 72, + paddingHorizontal: spacing.md, + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + }, + 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, + paddingBottom: spacing.xl, + width: "100%", + maxWidth: 760, + alignSelf: "center", + flexGrow: 1, + }, + empty: { + flex: 1, + paddingVertical: 48, + paddingHorizontal: spacing.xl, + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + emptyTitle: { + fontFamily, + color: colors.bright, + fontSize: 22, + fontWeight: "600", + letterSpacing: -0.4, + }, + composerWrap: { + paddingHorizontal: spacing.md, + paddingTop: 8, + paddingBottom: 8, + width: "100%", + maxWidth: 760, + alignSelf: "center", + gap: 4, + backgroundColor: colors.background, + }, + composer: { + 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, + fontSize: 16, + lineHeight: 23, + minHeight: 44, + maxHeight: 132, + textAlignVertical: "top", + paddingVertical: 10, + paddingHorizontal: 10, + }, + composerToolbar: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + 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: 10, + backgroundColor: colors.panel, + borderRadius: radii.pill, + }, + effortLabel: { + fontFamily, + color: colors.muted, + fontSize: 10, + fontWeight: "600", + flexShrink: 0, + paddingLeft: 6, + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: colors.border, + }, + 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: { + position: "absolute", + right: 20, + backgroundColor: colors.elevated, + borderRadius: radii.sheet, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, +}); diff --git a/packages/mobile/src/screens/CreateWorkspace.tsx b/packages/mobile/src/screens/CreateWorkspace.tsx new file mode 100644 index 00000000000..241f69a5496 --- /dev/null +++ b/packages/mobile/src/screens/CreateWorkspace.tsx @@ -0,0 +1,432 @@ +import { useEffect, useRef, useState } from "react"; +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, radii, spacing, typography } from "../theme"; +import { linkedAbortController } from "../useConnection"; +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"; +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; + signal: AbortSignal; + connected: boolean; + onReconnect: () => Promise; + projects: Projects; + onCreated: (workspace: FrontendWorkspaceMetadata) => void; + onClose: () => void; +}) { + const [project, setProject] = useState(null); + const [choosingProject, setChoosingProject] = useState(false); + const [title, setTitle] = useState(""); + const [branch, setBranch] = useState(""); + const [trunk, setTrunk] = 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); + 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); + const trunkInput = useRef(null); + + useEffect(() => { + const abort = linkedAbortController(props.signal); + controller.current = abort; + pending.current = false; + setBusy(false); + setBranches(null); + if (abort.signal.aborted || !project) { + 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, 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); + function refresh() { + // Listen first, and keep consuming invalidations while a read is pending. + 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()); + } + watchServerChanges(props.client, { + signal: lifetime.signal, + onOpen: refresh, + onEvent: (event) => { + if (event.type === "policy") refresh(); + }, + onLost: unavailable, + }).catch(unavailable); + return () => { + lifetime.abort(); + request?.abort(); + }; + }, [props.client, props.signal]); + + function selectProject(path: string | null) { + if (pending.current) return; + setChoosingProject(false); + if (path === project) return; + setProject(path); + setBranch(""); + setTrunk(""); + setBranches(null); + setError(null); + } + + 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 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())); + + async function create() { + if (pending.current || creationDisabled) return; + pending.current = true; + setBusy(true); + setError(null); + const signal = controller.current.signal; + try { + // Omitting runtimeConfig selects the server-owned worktree directory. + const result = project + ? await props.client.workspace.create( + { + projectPath: project, + branchName: branch.trim() || undefined, + trunkBranch: trunk.trim(), + title: title.trim() || undefined, + }, + { 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."} If the connection dropped, refresh the workspace list before trying again.` + ); + } finally { + if (controller.current.signal === signal) { + pending.current = false; + if (!signal.aborted) setBusy(false); + } + } + } + + const projectName = + selectedProject?.displayName ?? + project?.split(/[\\/]/).filter(Boolean).at(-1) ?? + "Scratch chat"; + return ( + { + if (!pending.current) props.onClose(); + }} + dismissDisabled={busy} + footer={ + <> + + {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. + + )} + + )} + + {policyMessage && ( + + {policyMessage} + + )} + {projectUnavailable && ( + + 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. + + )} + + branchInput.current?.focus() : create} + /> + {project && ( + <> + + trunkInput.current?.focus()} + /> + + {branches !== null && 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 new file mode 100644 index 00000000000..99188fc1b6d --- /dev/null +++ b/packages/mobile/src/screens/ModelSettings.tsx @@ -0,0 +1,267 @@ +import { useState } from "react"; +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 { Field, Sheet } from "../components/Controls"; +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" | "effort" | "agent" | "custom"; +const titles: Record = { + model: "Select model", + 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 ModelSettings(props: { + initialPage: "model" | "agent"; + value: ChatSettings; + data: SettingsData; + workspace: FrontendWorkspaceMetadata; + onChange: (value: ChatSettings) => void; + onClose: () => void; +}) { + const [page, setPage] = useState(props.initialPage); + const [query, setQuery] = useState(""); + const [customModel, setCustomModel] = useState(props.value.model); + const models = modelChoices(props.data, props.value.model); + // Search the entire Settings-visible catalog in the main sheet, not a provider shortlist. + const filtered = models.filter((model) => + modelMatchesSearch(model, query, providerName(model.split(":")[0])) + ); + const groups = new Map(); + for (const model of filtered) { + const provider = model.split(":")[0]; + 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(); + } + function modelRow(model: string, index: number) { + return ( + 0} + onPress={() => selectModel(model)} + /> + ); + } + const validCustom = /^\S+:\S+$/.test(customModel.trim()); + return ( + setPage("model") : undefined} + action={ + page === "custom" && ( + selectModel(customModel.trim())} + style={styles.done} + > + + Done + + + ) + } + > + {page === "model" && ( + <> + + + setPage("effort")} + disclosure + /> + + {[...groups].map(([provider, choices]) => ( + + {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) { + // Reasoning belongs to the target agent; legacy buckets resolve to Standard. + const target = resolveSettings(props.workspace, props.data, agent.id); + props.onChange( + props.value.model + ? { + ...props.value, + agentId: agent.id, + reasoningMode: target.reasoningMode ?? props.value.reasoningMode, + } + : target + ); + } + props.onClose(); + }} + /> + ))} + + )} + {page === "custom" && ( + <> + { + if (validCustom) selectModel(customModel.trim()); + }} + /> + + Enter a model supported by a configured server provider, in provider:model format. + + + )} + + ); +} + +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 }]} + > + + {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 }, + sectionLabel: { ...typography.footnote, color: colors.muted, paddingHorizontal: spacing.lg }, + row: { paddingHorizontal: spacing.lg }, + rowContent: { + minHeight: 56, + flexDirection: "row", + alignItems: "center", + gap: spacing.md, + paddingVertical: spacing.md, + }, + separator: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border }, + 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/Navigator.tsx b/packages/mobile/src/screens/Navigator.tsx new file mode 100644 index 00000000000..6ab4c80e3bb --- /dev/null +++ b/packages/mobile/src/screens/Navigator.tsx @@ -0,0 +1,336 @@ +import { useState } from "react"; +import { + Platform, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TextInput, + View, + useWindowDimensions, +} from "react-native"; +import { + ChevronDown, + ChevronRight, + Folder, + MessageSquare, + Plus, + Search, + Settings, + SquarePen, + X, +} from "lucide-react-native"; +import type { FrontendWorkspaceMetadata } from "../../../../src/common/types/workspace"; +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, + layout, + radii, + spacing, + typography, + WIDE_LAYOUT_MIN_WIDTH, +} 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; + 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(); + for (const [path, config] of props.projects) + groups.set(path, { + name: config.displayName ?? path.split(/[\\/]/).filter(Boolean).at(-1) ?? path, + 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, + workspaces: [], + }; + 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 ( + + + + {props.compact ? "Xum" : "Workspaces"} + + + + {!bottomDock && ( + + )} + + + {/* Keep one search input mounted across breakpoints; on phones it belongs in thumb reach. */} + + + + + + {query.length > 0 && ( + setQuery("")} /> + )} + + {bottomDock && ( + [styles.createButton, pressed && { opacity: 0.7 }]} + > + + New + + )} + + 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.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.parentWorkspaceId + ? `Subagent of ${workspaceNames.get(workspace.parentWorkspaceId) ?? workspace.parentWorkspaceId}` + : 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, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + content: { + paddingHorizontal: spacing.lg, + paddingTop: 8, + paddingBottom: 32, + gap: 16, + maxWidth: 760, + width: "100%", + alignSelf: "center", + }, + title: { ...typography.header, color: colors.bright, fontSize: 20, letterSpacing: -0.4 }, + search: { + flex: 1, + minWidth: 0, + backgroundColor: colors.panel, + borderRadius: radii.pill, + minHeight: 46, + flexDirection: "row", + alignItems: "center", + paddingLeft: 14, + paddingRight: 4, + gap: 8, + }, + searchInput: { + flex: 1, + minWidth: 0, + minHeight: 44, + ...typography.body, + color: colors.bright, + paddingVertical: 10, + }, + groupHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + minHeight: 44, + paddingHorizontal: 4, + }, + sectionTitle: { ...typography.footnote, color: colors.muted, fontWeight: "500" }, + section: { gap: 2 }, + workspace: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 12, + paddingVertical: 10, + minHeight: 60, + borderRadius: radii.control, + }, + workspaceTitle: { + fontFamily, + color: colors.bright, + fontSize: 16, + fontWeight: "500", + lineHeight: 22, + }, + empty: { paddingVertical: 24, gap: 14, alignItems: "center" }, +}); diff --git a/packages/mobile/src/screens/SettingsScreen.tsx b/packages/mobile/src/screens/SettingsScreen.tsx new file mode 100644 index 00000000000..d87168c4c33 --- /dev/null +++ b/packages/mobile/src/screens/SettingsScreen.tsx @@ -0,0 +1,126 @@ +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; + onDisconnect: () => void; + onBack: () => void; + error: string | null; + busy: boolean; +}) { + const [confirming, setConfirming] = useState(false); + return ( + +
+ + + 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."} + + + + + + + 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. + + + + + + {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..60efa983778 --- /dev/null +++ b/packages/mobile/src/screens/formTestPlatform.ts @@ -0,0 +1,58 @@ +import "../testDom"; +import { mock } from "bun:test"; +import { createElement } from "react"; +import { TOOL_ICON_NAMES } from "../../../../src/common/constants/toolIcons"; +// @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("react-native-svg", () => ({ default: NativeWeb.View, Circle: icon })); +mock.module("lucide-react-native", () => + Object.fromEntries( + [ + ...new Set([ + ...Object.values(TOOL_ICON_NAMES), + "ArrowDown", + "ArrowUp", + "ArrowRight", + "Eye", + "EyeOff", + "GitCompareArrows", + "Square", + "CheckCircle2", + "FileCode", + "RefreshCw", + "AlertCircle", + "ChevronLeft", + "Info", + "TriangleAlert", + "X", + "Check", + "ChevronDown", + "ChevronRight", + "Cpu", + "Bot", + "ClipboardList", + "Code2", + "Folder", + "Search", + "Settings", + "SquarePen", + "Plus", + "MessageSquare", + "KeyRound", + "LogOut", + "Server", + "ShieldCheck", + "Brain", + "File", + "Pause", + "Wrench", + ]), + ].map((name) => [name, () => createElement("svg", { "data-icon": name, "aria-hidden": true })]) + ) +); diff --git a/packages/mobile/src/screens/forms.behavior.tsx b/packages/mobile/src/screens/forms.behavior.tsx new file mode 100644 index 00000000000..a73767770d3 --- /dev/null +++ b/packages/mobile/src/screens/forms.behavior.tsx @@ -0,0 +1,2348 @@ +import "./formTestPlatform"; +import { afterEach, expect, test } from "bun:test"; +import { createRef, useState } from "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"; +import type { MuxMessage, MuxToolPart } from "../../../../src/common/types/message"; +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 { 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"; + +afterEach(cleanup); + +const workspace: FrontendWorkspaceMetadata = { + id: "workspace", + name: "feature", + projectName: "project", + projectPath: "/project", + namedWorkspacePath: "/project/feature", + 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("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("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(); + 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 keeps roots by default but opens matching agents through search", () => { + const selected: string[] = []; + 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: (value: FrontendWorkspaceMetadata) => selected.push(value.id), + 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" } }); + 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( + + ); + 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: [] }; + 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("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( + <> + 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); +}); + +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(".") === "server.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) => { + resolve = done; + }); + let calls = 0; + let dismissals = 0; + let selected: FrontendWorkspaceMetadata | undefined; + const client = createFormClient({ + 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; + }} + /> + ); + 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" })); + 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.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 = createFormClient({ + 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)); + 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 }); + 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]); + } +); + +test.each(["restore", "reselect"])( + "removed project blocks creation without losing drafts, then %s recovers", + async (recovery) => { + const calls: unknown[] = []; + const client = createFormClient({ + 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", trusted: true }], + ["/available", { workspaces: [], displayName: "Available", trusted: true }], + ]; + 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", + }); + } +); + +test.each(["root", "subproject"])( + "creation follows live owner trust and preserves drafts (%s)", + async (kind) => { + const calls: unknown[] = []; + let branchReads = 0; + const client = createFormClient({ + 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 = createFormClient({ + 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 = createFormClient({ + 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" }); +}); + +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 === "server.onChanged") { + order.push(method); + return new ReadableStream<{ type: "policy" }>({ + start(controller) { + let closed = false; + const close = () => { + if (!closed) { + closed = true; + controller.close(); + } + }; + subscriptions.push({ + signal: options.signal, + emit: () => controller.enqueue({ type: "policy" }), + 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(["server.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", + thinkingLevel: "high", + providerOptions: { + anthropic: { + disableBetaFeatures: true, + cacheTtl: "1h", + use1MContextModels: ["anthropic:claude-sonnet-4-20250514"], + }, + google: { cache: false }, + }, +}; +const pickerData: SettingsData = { + policy: { source: "none", status: { state: "disabled" }, policy: null }, + config: { agentAiDefaults: {}, defaultModel: "local:one", hiddenModels: ["other:hidden"] }, + providers: { + local: { + isConfigured: true, + isEnabled: true, + 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 }, + { + 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 ( + { + setValue(next); + props.onChange(next); + }} + onClose={props.onClose} + /> + ); +} + +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 "policy.get": + return pickerData.policy; + case "server.onChanged": + return new ReadableStream({ + start(controller) { + request.signal?.addEventListener("abort", () => controller.close(), { once: true }); + }, + }).values(); + 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={{ text: "Keep preferences", fileParts: [], reviews: [] }} + 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", + allowAgentSetGoal: true, + 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; + const view = render( + changes.push(value)} + onClose={() => { + closed++; + }} + /> + ); + expect(view.getByRole("radio", { name: "local:one" }).getAttribute("aria-checked")).toBe("true"); + 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); +}); + +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: "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.each([ + { source: "pro", target: "standard", bucket: true, expected: "standard" }, + { source: "pro", target: undefined, bucket: true, expected: "standard" }, + { source: "standard", target: "pro", bucket: true, expected: "pro" }, + { source: "pro", target: undefined, bucket: false, expected: "pro" }, +] as const)("mode selection uses the target reasoning mode: %j", (scenario) => { + const changes: ChatSettings[] = []; + const selected: ChatSettings = { + ...pickerValue, + model: "coder:openai/gpt-6-astra", + reasoningMode: scenario.source, + }; + const view = render( + changes.push(next)} + onClose={() => {}} + /> + ); + fireEvent.click(view.getByRole("radio", { name: /Plan/ })); + expect(changes).toEqual([{ ...selected, agentId: "plan", reasoningMode: scenario.expected }]); +}); + +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("searching the main picker spans providers without changing selection", () => { + const changes: ChatSettings[] = []; + const view = render( + changes.push(value)} onClose={() => {}} /> + ); + 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.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); + 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", () => { + const changes: ChatSettings[] = []; + let closed = 0; + const view = render( + changes.push(value)} + onClose={() => { + closed++; + }} + /> + ); + 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" })); + 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", () => { + 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); +}); + +test("Markdown separates headings and hanging list items while preserving literal hostile text", () => { + 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.each([ + ["bash", "Wrench"], + ["ask_user_question", "MessageCircleQuestion"], + ["file_edit_replace_string", "Pencil"], + ["file_read", "BookOpen"], + ["server:GOOGLE_SEARCH_WEB", "Globe"], + ["mcp__custom__search", "Sparkles"], + ["unknown_tool", "Sparkles"], + ["constructor", "Sparkles"], + ["__proto__", "Sparkles"], +])("tool header %s renders its semantic glyph without changing inspection", (toolName, icon) => { + const part: MuxToolPart = { + type: "dynamic-tool", + toolCallId: "icon-call", + toolName, + input: {}, + state: "output-available", + output: "Inspect this result", + }; + const view = render( + {}} /> + ); + const header = view.getByRole("button"); + expect(header.querySelector(`svg[data-icon="${icon}"]`)).not.toBeNull(); + fireEvent.click(header); + expect(view.getByText("Inspect this result")).toBeDefined(); + fireEvent.click(view.getByRole("button", { name: "Close" })); + 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("legacy toolCalls replay renders inspectable children without overriding explicit nestedCalls", () => { + 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", + 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", + executionStartedAt: 0, + 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( + { + answers.push(value); + }} + /> + ); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(answers).toHaveLength(0); + const branch = within(view.getByRole("radiogroup", { name: "Which branch?" })); + expect(branch.getByText("Upcoming release")).toBeDefined(); + fireEvent.click(branch.getByRole("radio", { name: "next" })); + fireEvent.click(branch.getByRole("radio", { name: "main" })); + expect(branch.getByRole("radio", { name: "next" }).getAttribute("aria-checked")).toBe("false"); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(answers).toHaveLength(0); + fireEvent.click( + within(view.getByRole("radiogroup", { name: "What should change?" })).getByRole("radio", { + name: "Other", + }) + ); + fireEvent.click(view.getByRole("button", { name: "Send answers" })); + expect(answers).toHaveLength(0); + fireEvent.change(view.getByLabelText("Other: 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.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); + } +); + +function prefilledQuestionPart( + answers: unknown, + multiSelect = false, + toolCallId = "prefilled", + questionText = "Which branch?" +): MuxToolPart { + return { + type: "dynamic-tool", + toolCallId, + toolName: "ask_user_question", + state: "input-available", + input: { + questions: [ + { + question: questionText, + header: "Branch", + options: [ + { label: "main", description: "Stable branch" }, + { label: "next", description: "Upcoming release" }, + ], + multiSelect, + }, + ], + answers, + }, + }; +} + +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" }, + { 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.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( + {}} + /> + ); + 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", + 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", + 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(); +}); 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/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 new file mode 100644 index 00000000000..09239acc35d --- /dev/null +++ b/packages/mobile/src/screens/session.behavior.tsx @@ -0,0 +1,1846 @@ +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, 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"; +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 }; +} + +type Policy = Awaited>; +const disabledPolicy: Policy = { source: "none", status: { state: "disabled" }, policy: null }; +function fixture( + messages: WorkspaceChatMessage[] = [], + wide = false, + initialPolicy: Policy | Error = disabledPolicy, + initialWorkspaces: FrontendWorkspaceMetadata[] = workspaces +) { + let workspaceList = initialWorkspaces; + type ChangeEvent = + Awaited> extends AsyncIterable + ? Event + : never; + const changeEvents: ReadableStreamDefaultController[] = []; + 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; + fail: (cause: unknown) => void; + 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"] }, + }; + 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: "scout", name: "Scout", uiSelectable: true }, + { id: "plan", name: "Plan", uiSelectable: true }, + ].map((agent) => ({ ...agent, scope: "built-in", subagentRunnable: true })); + let configRead = async () => config; + let policy = initialPolicy; + let closed = 0; + let reconnected = 0; + let disconnected = 0; + let send = async (): Promise => ({ success: true }); + let interrupt = async (): Promise => ({ success: true }); + 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, + fail: (cause: unknown) => void + ) => void + ) { + return new ReadableStream({ + start(controller) { + let ended = false; + const end = () => { + if (ended) return; + 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, fail); + }, + }).values(); + } + const client = createORPCClient({ + call: async (path, input, options) => { + const name = path.join("."); + calls.push({ path: name, input, signal: options.signal }); + switch (name) { + case "server.onChanged": + return events(options.signal, [], (controller) => changeEvents.push(controller)); + case "workspace.list": + return workspaceList; + case "projects.list": + return []; + case "policy.get": + if (policy instanceof Error) throw policy; + return policy; + case "config.getConfig": + return configRead(); + case "providers.getConfig": + return providers; + case "agents.list": + return agents; + 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, fail) => + chats.push({ workspaceId, signal, events: controller, end, fail }) + ); + } + case "workspace.answerAskUserQuestion": + return answer(); + case "workspace.resumeStream": + return resume(); + case "workspace.interruptStream": + return interrupt(); + case "workspace.sendMessage": + return send(); + case "workspace.executeBash": + return { success: true, data: { success: true, output: "" } }; + case "workspace.getProjectDiffs": + return []; + 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; + }, + setSend(value: typeof send) { + send = value; + }, + setInterrupt(value: typeof interrupt) { + interrupt = value; + }, + 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 model" }).getAttribute("aria-disabled") + ).not.toBe("true") + ); + }, + async updateWorkspace(metadata: FrontendWorkspaceMetadata) { + workspaceList = workspaceList.map((workspace) => + workspace.id === metadata.id ? metadata : workspace + ); + 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 () => changeEvents.at(-1)!.enqueue({ type: "config" })); + }, + setConfigRead(read: typeof configRead) { + configRead = read; + }, + async updateConfig(next: SettingsData["config"]) { + config = next; + await act(async () => changeEvents.at(-1)!.enqueue({ type: "config" })); + }, + async updateProviders(next: SettingsData["providers"]) { + providers = next; + await act(async () => changeEvents.at(-1)!.enqueue({ type: "providers" })); + }, + async updatePolicy(next: Policy) { + policy = next; + await act(async () => changeEvents.at(-1)!.enqueue({ type: "policy" })); + }, + async emit(event: WorkspaceChatMessage) { + await act(async () => chats.at(-1)!.events.enqueue(event)); + }, + }; +} + +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"], + [{ 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(); + 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); + 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(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); +}); + +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(), + { + type: "tool-call-execution-start", + workspaceId: "alpha", + messageId: "question", + toolCallId: "question", + timestamp: 1, + }, + ] + : [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("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: "Scout" })); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Keep this draft" } }); + 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" + ); + 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: "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: "scout" }, + }); +}); + +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"); + 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); + 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"); + 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); + // A dropped conversation stream heals on its own; the session itself stays connected. + await act(async () => view.chats[0].end()); + 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" })); + 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 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, + 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: questionInput(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.click( + within(view.getByRole("radiogroup", { name: `Answer ${id}?` })).getByRole("radio", { + name: "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", allowAgentSetGoal: true }, + }); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); +}); + +test.each(["ready", "route-lost", "policy-blocked", "settings-failed"] as const)( + "live answers bypass only next-turn routing, not global settings blocks (%s)", + async (availability) => { + 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: questionInput("live"), + timestamp: 1, + executionStartedAt: 1, + }, + ]); + await view.select("alpha"); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Next-turn draft" } }); + if (availability === "route-lost") await view.updateProviders({}); + if (availability === "policy-blocked") + await view.updatePolicy({ + source: "env", + status: { state: "blocked", reason: "minimum_client_version requires server upgrade" }, + policy: null, + }); + if (availability === "settings-failed") { + view.setConfigRead(() => Promise.reject(new Error("settings unavailable"))); + await view.updateConfig({ agentAiDefaults: {} }); + } + if (availability !== "ready") expect(view.getAllByRole("alert").length).toBeGreaterThan(0); + 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))); + const allowed = availability === "ready" || availability === "route-lost"; + expect(callCount(view, "answerAskUserQuestion")).toBe(allowed ? 1 : 0); + expect(callCount(view, "resumeStream")).toBe(0); + expect(view.getByLabelText("Message")).toHaveProperty("value", "Next-turn draft"); + } +); + +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( + within(view.getByRole("radiogroup", { name: "Answer old?" })) + .getByRole("radio", { name: "main" }) + .getAttribute("aria-disabled") + ).toBe("true"); + 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( + within(view.getByRole("radiogroup", { name: "Answer complete?" })) + .getByRole("radio", { name: "main" }) + .getAttribute("aria-disabled") + ).toBe("true"); +}); + +function answeredPartial(id = "question", partial = true) { + const message = question(id, 1, partial); + return { + ...message, + parts: message.parts.map((part) => + part.type === "dynamic-tool" + ? { ...part, state: "output-available" as const, output: { summary: "answered" } } + : part + ), + }; +} + +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()); + act(() => wakeStreams()); + 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]); + await view.select("alpha"); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); + expect(callCount(view, "resumeStream")).toBe(0); + view.setResume(async () => ({ success: true, data: { started: false } })); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Resume agent" }))); + expect(callCount(view, "resumeStream")).toBe(1); + view.setResume(async () => { + throw new Error("offline"); + }); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Resume agent" }))); + expect(view.getByRole("alert").textContent).toContain("offline"); + const resume = deferred(); + view.setResume(() => resume.promise); + const retry = view.getByRole("button", { name: "Resume agent" }); + fireEvent.click(retry); + fireEvent.click(retry); + expect(callCount(view, "resumeStream")).toBe(3); + await act(async () => resume.resolve({ success: true, data: { started: true } })); + await view.emit(saved); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Keep draft" } }); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + expect(callCount(view, "answerAskUserQuestion")).toBe(0); + await act(async () => view.chats[0].end()); + act(() => wakeStreams()); + await waitFor(() => expect(view.chats).toHaveLength(2)); + expect(await view.findByRole("button", { name: "Resume agent" })).toBeDefined(); + expect(callCount(view, "resumeStream")).toBe(3); +}); + +test("an answered partial retains manual recovery after switching away and remounting", 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 }; + }); + view.setResume(async () => ({ success: false, error: "provider unavailable" })); + await submitAnswer(view); + fireEvent.click(view.getByRole("button", { name: "Back to workspaces" })); + await view.select("alpha"); + expect(view.queryByRole("button", { name: "Send answers" })).toBeNull(); + expect(view.getByRole("button", { name: "Resume agent" })).toBeDefined(); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(1); +}); + +test("completed answers do not enable recovery for historical rows, completed turns or active streams", async () => { + const saved = answeredPartial(); + for (const messages of [ + [answeredPartial("complete", false)], + [saved, question("newer", 2)], + [ + saved, + { type: "message", id: "user", role: "user", parts: [], metadata: { historySequence: 2 } }, + ], + [ + saved, + { type: "stream-lifecycle", workspaceId: "alpha", phase: "streaming", hadAnyOutput: true }, + ], + ] satisfies WorkspaceChatMessage[][]) { + const view = fixture(messages); + await view.select("alpha"); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + expect(callCount(view, "resumeStream")).toBe(0); + view.unmount(); + } +}); + +test("durable answer recovery still waits for policy and live settings", async () => { + const view = fixture([answeredPartial()], false, { + source: "env", + status: { state: "blocked", reason: "upgrade required" }, + policy: null, + }); + await view.select("alpha"); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + const config = deferred(); + view.setConfigRead(() => config.promise); + await view.updateConfig({ agentAiDefaults: {} }); + await view.updatePolicy(disabledPolicy); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + const providerOptions = { anthropic: { disableBetaFeatures: true } }; + await act(async () => + config.resolve({ agentAiDefaults: {}, userPreferences: { ai: { providerOptions } } }) + ); + await act(async () => fireEvent.click(await view.findByRole("button", { name: "Resume agent" }))); + expect(callCount(view, "answerAskUserQuestion")).toBe(0); + expect(view.calls.find((call) => call.path === "workspace.resumeStream")?.input).toMatchObject({ + options: { providerOptions }, + }); +}); + +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"); + 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("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 })); + await submitAnswer(view); + 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" }, + }); +}); + +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( + 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 () => { + 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 }, + }); +}); + +function restoredInput(): Extract { + return { + type: "restore-to-input", + workspaceId: "alpha", + text: "Queued follow-up", + fileParts: [ + { filename: "queue.txt", mediaType: "text/plain", url: "data:text/plain;base64,cXVldWU=" }, + ], + reviews: [ + { + filePath: "src/queue.ts", + lineRange: "10-11", + selectedCode: "const next = 1;", + selectedDiff: "+const next = 1;", + oldStart: 10, + newStart: 10, + userNote: "Preserve the queued input", + }, + ], + }; +} + +test("interrupt restores the full queue into the existing draft once and keeps it across navigation/reconnect", async () => { + const restored = restoredInput(); + const replay: WorkspaceChatMessage[] = [ + { + type: "stream-start", + workspaceId: "alpha", + messageId: "live", + historySequence: 1, + startTime: 1, + model, + }, + { + type: "queued-message-changed", + workspaceId: "alpha", + queuedMessages: [restored.text], + displayText: restored.text, + fileParts: restored.fileParts, + reviews: restored.reviews, + }, + ]; + const view = fixture(replay); + await view.select("alpha"); + expect(view.getByLabelText("Message")).toHaveProperty("value", ""); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Existing draft" } }); + view.setInterrupt(async () => { + view.chats[0].events.enqueue(restored); + const idle = { + type: "stream-lifecycle", + workspaceId: "alpha", + phase: "idle", + hadAnyOutput: true, + } as const; + view.chats[0].events.enqueue(idle); + replay.splice(0, replay.length, idle); + return { success: true }; + }); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Interrupt agent" }))); + expect(view.calls.find((call) => call.path === "workspace.interruptStream")?.input).toEqual({ + workspaceId: "alpha", + options: { retireBashMonitorAttention: true, disableAutoRetry: true }, + }); + expect(view.getByLabelText("Message")).toHaveProperty( + "value", + "Existing draft\n\nQueued follow-up" + ); + expect(view.getByText("queue.txt")).toBeDefined(); + expect(view.getByText("1 review note")).toBeDefined(); + await view.emit({ ...restored, workspaceId: "beta", text: "Wrong workspace" }); + expect(callCount(view, "onChat")).toBe(1); + expect(view.getByLabelText("Message")).toHaveProperty( + "value", + "Existing draft\n\nQueued follow-up" + ); + fireEvent.click(view.getByRole("button", { name: "Back to workspaces" })); + await view.select("alpha"); + await act(async () => view.chats.at(-1)!.end()); + act(() => wakeStreams()); + await waitFor(() => expect(view.chats).toHaveLength(3)); + expect(view.getByLabelText("Message")).toHaveProperty( + "value", + "Existing draft\n\nQueued follow-up" + ); + expect(view.getAllByText("queue.txt")).toHaveLength(1); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + const request = view.calls.find((call) => call.path === "workspace.sendMessage")?.input; + expect(request).toMatchObject({ + options: { fileParts: restored.fileParts, muxMetadata: { reviews: restored.reviews } }, + }); + expect(request).toHaveProperty( + "message", + expect.stringContaining("Existing draft\n\nQueued follow-up") + ); + expect(request).toHaveProperty("message", expect.stringContaining(restored.reviews![0].userNote)); + expect(request).toHaveProperty( + "message", + expect.stringContaining(restored.reviews![0].selectedCode) + ); + expect(view.getByLabelText("Message")).toHaveProperty("value", ""); + expect(view.queryByText("queue.txt")).toBeNull(); + expect(view.queryByText("1 review note")).toBeNull(); +}); + +test("failed sends preserve full restored drafts and successful sends cannot erase newer text or extras", async () => { + const restored = restoredInput(); + const view = fixture(); + await view.select("alpha"); + await view.emit(restored); + view.setSend(async () => ({ success: false, error: "send unavailable" })); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Send message" }))); + expect(view.getByRole("alert").textContent).toContain("send unavailable"); + expect(view.getByLabelText("Message")).toHaveProperty("value", restored.text); + expect(view.getByText("queue.txt")).toBeDefined(); + expect(view.getByText("1 review note")).toBeDefined(); + const send = deferred(); + view.setSend(() => send.promise); + fireEvent.click(view.getByRole("button", { name: "Send message" })); + fireEvent.click(view.getByRole("button", { name: "Send message" })); + expect(callCount(view, "sendMessage")).toBe(2); + expect(view.getByLabelText("Message").getAttribute("readonly")).toBeNull(); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "New draft" } }); + await view.emit({ + ...restored, + text: "Another restore", + fileParts: [{ ...restored.fileParts![0], filename: "new.txt" }], + }); + expect(view.getByText("queue.txt")).toBeDefined(); + fireEvent.click(view.getByRole("button", { name: "Remove attachment queue.txt" })); + await act(async () => send.resolve({ success: true })); + expect(view.getByLabelText("Message")).toHaveProperty("value", "New draft\n\nAnother restore"); + expect(view.queryByText("queue.txt")).toBeNull(); + expect(view.getByText("new.txt")).toBeDefined(); + expect(view.getByText("2 review notes")).toBeDefined(); + expect(callCount(view, "onChat")).toBe(1); + expect(view.calls.filter((call) => call.path === "workspace.sendMessage")[1].input).toMatchObject( + { + options: { fileParts: restored.fileParts, muxMetadata: { reviews: restored.reviews } }, + } + ); + fireEvent.click(view.getByRole("button", { name: "Remove review notes" })); + expect(view.queryByText("2 review notes")).toBeNull(); +}); + +for (const extra of ["attachment", "review"] as const) { + test(`${extra}-only restored drafts send without text`, async () => { + const restored = restoredInput(); + const view = fixture(); + await view.select("alpha"); + await view.emit({ + ...restored, + text: "", + fileParts: extra === "attachment" ? restored.fileParts : [], + reviews: extra === "review" ? restored.reviews : [], + }); + expect(view.getByLabelText("Message")).toHaveProperty("value", ""); + 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( + extra === "attachment" + ? { message: "", options: { fileParts: restored.fileParts } } + : { options: { muxMetadata: { reviews: restored.reviews } } } + ); + expect(view.getByRole("button", { name: "Send message" }).getAttribute("aria-disabled")).toBe( + "true" + ); + }); +} + +test("live interruption remains available during pending and failed settings refreshes, but not a lost transport", async () => { + const view = fixture([ + { + type: "stream-start", + workspaceId: "alpha", + messageId: "live", + historySequence: 1, + startTime: 1, + model, + }, + { + type: "tool-call-start", + workspaceId: "alpha", + messageId: "live", + toolCallId: "live", + toolName: "ask_user_question", + tokens: 1, + args: questionInput("live"), + timestamp: 1, + executionStartedAt: 1, + }, + ]); + await view.select("alpha"); + const read = deferred(); + view.setConfigRead(() => read.promise); + await view.updateConfig({ agentAiDefaults: {} }); + const interrupt = () => view.getByRole("button", { name: "Interrupt agent" }); + expect(interrupt().getAttribute("aria-disabled")).not.toBe("true"); + expect(view.getByRole("button", { name: "Send answers" }).getAttribute("aria-disabled")).toBe( + "true" + ); + await act(async () => fireEvent.click(interrupt())); + expect(callCount(view, "interruptStream")).toBe(1); + await act(async () => read.reject(new Error("settings unavailable"))); + expect(view.getByRole("alert")).toBeDefined(); + expect(interrupt().getAttribute("aria-disabled")).not.toBe("true"); + await act(async () => fireEvent.click(interrupt())); + expect(callCount(view, "interruptStream")).toBe(2); + expect(callCount(view, "sendMessage")).toBe(0); + expect(callCount(view, "answerAskUserQuestion")).toBe(0); + expect(callCount(view, "resumeStream")).toBe(0); + await act(async () => view.chats[0].end()); + expect(view.queryByRole("button", { name: "Interrupt agent" })).toBeNull(); + expect(view.getByRole("button", { name: "Send message" }).getAttribute("aria-disabled")).toBe( + "true" + ); +}); + +test("policy-off live provider loss retains selection and blocks send and answers until restored", async () => { + const view = fixture([question()]); + await view.select("alpha"); + fireEvent.change(view.getByLabelText("Message"), { target: { value: "Keep this draft" } }); + await view.updateProviders({ + anthropic: { isEnabled: false, isConfigured: false, apiKeySet: true }, + }); + expect(view.getByRole("button", { name: "Send message" }).getAttribute("aria-disabled")).toBe( + "true" + ); + expect(view.getByRole("button", { name: "Send answers" }).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: model })); + await view.updateProviders({ + anthropic: { isEnabled: true, isConfigured: true, apiKeySet: true }, + }); + await submitAnswer(view); + expect(callCount(view, "answerAskUserQuestion")).toBe(1); + expect(callCount(view, "resumeStream")).toBe(1); + expect(view.getByLabelText("Message")).toHaveProperty("value", "Keep this draft"); +}); + +test("lost credentials during answer saving block resume until a valid route is restored", async () => { + const view = fixture([question()]); + await view.select("alpha"); + const answer = deferred(); + view.setAnswer(() => answer.promise); + await submitAnswer(view); + await view.updateProviders({ + anthropic: { isEnabled: true, isConfigured: false, apiKeySet: false }, + }); + await view.emit(answered()); + await act(async () => answer.resolve({ success: true })); + expect(callCount(view, "resumeStream")).toBe(0); + expect(view.queryByRole("button", { name: "Resume agent" })).toBeNull(); + await view.updateProviders({ + anthropic: { isEnabled: true, isConfigured: true, apiKeySet: true }, + }); + 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 unavailable current route does not disable interruption of its already-running stream", async () => { + const view = fixture([ + { + type: "stream-start", + workspaceId: "alpha", + messageId: "live", + historySequence: 1, + startTime: 1, + model, + }, + ]); + await view.select("alpha"); + await view.updateProviders({}); + expect(view.getByRole("alert")).toBeDefined(); + await act(async () => fireEvent.click(view.getByRole("button", { name: "Interrupt agent" }))); + expect(callCount(view, "interruptStream")).toBe(1); +}); + +test("live direct-auth and routing changes gate the current OpenAI selection without blocking a valid gateway", async () => { + const view = fixture(); + await view.select("alpha"); + const openai = { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + codexOauthSet: false, + models: ["gpt-4o"], + }; + await view.updateProviders({ openai }); + fireEvent.click(view.getByRole("button", { name: "Choose model" })); + fireEvent.click(view.getByRole("radio", { name: "openai:gpt-4o" })); + fireEvent.change(view.getByLabelText("Message"), { + target: { value: "Use the available route" }, + }); + const send = () => view.getByRole("button", { name: "Send message" }); + expect(send().getAttribute("aria-disabled")).not.toBe("true"); + const oauthOnly = { ...openai, apiKeySet: false, codexOauthSet: true }; + await view.updateProviders({ openai: oauthOnly }); + expect(send().getAttribute("aria-disabled")).toBe("true"); + const coder = { + isConfigured: true, + isEnabled: true, + apiKeySet: false, + models: ["openai/gpt-4o"], + discoveredModels: ["openai/gpt-4o"], + }; + await view.updateConfig({ agentAiDefaults: {}, routePriority: ["coder", "direct"] }); + await view.updateProviders({ openai: oauthOnly, coder }); + expect(send().getAttribute("aria-disabled")).not.toBe("true"); + await view.updateConfig({ + agentAiDefaults: {}, + routePriority: ["coder", "direct"], + routeOverrides: { "openai:gpt-4o": "direct" }, + }); + expect(send().getAttribute("aria-disabled")).toBe("true"); + await view.updateConfig({ agentAiDefaults: {}, routePriority: ["coder", "direct"] }); + await view.updateProviders({ + openai: oauthOnly, + coder: { ...coder, removedModels: ["openai/gpt-4o"] }, + }); + expect(send().getAttribute("aria-disabled")).toBe("true"); + await view.updateProviders({ openai: oauthOnly, coder }); + await act(async () => fireEvent.click(send())); + expect(callCount(view, "sendMessage")).toBe(1); + expect(view.calls.find((call) => call.path === "workspace.sendMessage")?.input).toMatchObject({ + options: { model: "openai:gpt-4o" }, + }); +}); + +describe("web composer keyboard", () => { + const originalMatchMedia = window.matchMedia; + const platform = Object.getOwnPropertyDescriptor(navigator, "platform"); + afterEach(() => { + window.matchMedia = originalMatchMedia; + if (platform) Object.defineProperty(navigator, "platform", platform); + else Reflect.deleteProperty(navigator, "platform"); + }); + function keyboardMedia(fineAndWide: boolean) { + const media = originalMatchMedia.call(window, ""); + Object.defineProperty(media, "matches", { value: fineAndWide }); + window.matchMedia = () => media; + } + function key( + target: Element | Document, + value: string, + options: KeyboardEventInit = {}, + legacyCode?: number + ) { + const event = new window.KeyboardEvent("keydown", { + key: value, + bubbles: true, + cancelable: true, + ...options, + }); + if (legacyCode != null) Object.defineProperty(event, "keyCode", { value: legacyCode }); + fireEvent(target, event); + return event.defaultPrevented; + } + const live: WorkspaceChatMessage = { + type: "stream-start", + workspaceId: "alpha", + messageId: "keyboard-live", + model, + historySequence: 1, + startTime: 1, + }; + + test("fine/wide Enter sends once and duplicate keys while busy remain unhandled", async () => { + keyboardMedia(true); + const view = fixture([], true); + await view.select("alpha"); + const input = view.getByLabelText("Message"); + fireEvent.change(input, { target: { value: "keyboard message" } }); + act(() => input.focus()); + const pendingSend = deferred(); + view.setSend(() => pendingSend.promise); + act(() => { + expect(key(input, "Enter")).toBe(true); + expect(key(input, "Enter")).toBe(false); + }); + expect(callCount(view, "sendMessage")).toBe(1); + expect(view.calls.find((call) => call.path === "workspace.sendMessage")?.input).toMatchObject({ + workspaceId: "alpha", + message: "keyboard message", + }); + expect(key(input, "Escape")).toBe(false); + expect(callCount(view, "interruptStream")).toBe(0); + await act(async () => pendingSend.resolve({ success: true })); + }); + + test.each(["ctrlKey", "metaKey"] as const)( + "narrow/coarse Enter stays a newline and idle %s+Enter sends", + async (modifier) => { + keyboardMedia(false); + Object.defineProperty(navigator, "platform", { configurable: true, value: "MacIntel" }); + const view = fixture(); + await view.select("alpha"); + const input = view.getByLabelText("Message"); + fireEvent.change(input, { target: { value: "keep software Enter" } }); + act(() => input.focus()); + expect(key(input, "Enter")).toBe(false); + expect(key(input, "Enter", { shiftKey: true })).toBe(false); + expect(callCount(view, "sendMessage")).toBe(0); + await act(async () => { + expect(key(input, "Enter", { [modifier]: true })).toBe(true); + }); + expect(callCount(view, "sendMessage")).toBe(1); + } + ); + + test("IME, repeat, extra modifiers, lost focus and modal focus do not send", async () => { + keyboardMedia(true); + const view = fixture([], true); + await view.select("alpha"); + const input = view.getByLabelText("Message"); + fireEvent.change(input, { target: { value: "compose safely" } }); + act(() => input.focus()); + expect(getWebComposerKeyAction({ key: "Enter", target: input })).toBeUndefined(); + const claimed = new window.KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }); + claimed.preventDefault(); + fireEvent(input, claimed); + for (const options of [ + { shiftKey: true }, + { altKey: true }, + { ctrlKey: true, shiftKey: true }, + { repeat: true }, + { isComposing: true }, + ]) { + expect(key(input, "Enter", options)).toBe(false); + } + expect(key(input, "Enter", {}, 229)).toBe(false); + act(() => input.blur()); + expect(key(input, "Enter")).toBe(false); + expect(key(document, "Enter")).toBe(false); + fireEvent.click(view.getByRole("button", { name: "Choose model" })); + expect(key(view.getByLabelText("Search models"), "Enter")).toBe(false); + act(() => input.focus()); + expect(key(input, "Enter")).toBe(false); + expect(callCount(view, "sendMessage")).toBe(0); + }); + + test("empty, route-blocked and disconnected sends do not consume Enter", async () => { + keyboardMedia(true); + const view = fixture([], true); + await view.select("alpha"); + const input = view.getByLabelText("Message"); + act(() => input.focus()); + expect(key(input, "Enter")).toBe(false); + fireEvent.change(input, { target: { value: "blocked draft" } }); + await view.updateProviders({}); + expect(key(input, "Enter")).toBe(false); + expect(key(input, "Enter", { ctrlKey: true })).toBe(false); + await act(async () => view.chats[0].end()); + expect(key(input, "Enter")).toBe(false); + expect(callCount(view, "sendMessage")).toBe(0); + expect(callCount(view, "interruptStream")).toBe(0); + }); + + test("only focused eligible Escape stops; Enter never stops or queues a running turn", async () => { + keyboardMedia(true); + const view = fixture([live], true); + await view.select("alpha"); + const input = view.getByLabelText("Message"); + fireEvent.change(input, { target: { value: "next draft" } }); + expect(key(document, "Escape")).toBe(false); + act(() => input.blur()); + expect(key(input, "Escape")).toBe(false); + act(() => input.focus()); + expect(key(input, "Escape", { isComposing: true })).toBe(false); + expect(key(input, "Escape", {}, 229)).toBe(false); + expect(key(input, "Escape", { repeat: true })).toBe(false); + expect(key(input, "Escape", { ctrlKey: true })).toBe(false); + expect(key(input, "Enter")).toBe(false); + expect(key(input, "Enter", { ctrlKey: true })).toBe(false); + const pendingStop = deferred(); + view.setInterrupt(() => pendingStop.promise); + act(() => { + expect(key(input, "Escape")).toBe(true); + expect(key(input, "Escape")).toBe(false); + }); + expect(callCount(view, "sendMessage")).toBe(0); + expect(callCount(view, "interruptStream")).toBe(1); + expect(view.calls.find((call) => call.path === "workspace.interruptStream")?.input).toEqual({ + workspaceId: "alpha", + options: { retireBashMonitorAttention: true, disableAutoRetry: true }, + }); + await act(async () => pendingStop.resolve({ success: true })); + }); + + test("Escape retains active Stop eligibility when the next route is blocked", async () => { + keyboardMedia(false); + const view = fixture([live]); + await view.select("alpha"); + await view.updateProviders({}); + const input = view.getByLabelText("Message"); + act(() => input.focus()); + await act(async () => { + expect(key(input, "Escape")).toBe(true); + }); + expect(callCount(view, "interruptStream")).toBe(1); + await act(async () => view.chats[0].end()); + expect(key(input, "Escape")).toBe(false); + 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/screens/session.test.ts b/packages/mobile/src/screens/session.test.ts new file mode 100644 index 00000000000..dcbfd335c82 --- /dev/null +++ b/packages/mobile/src/screens/session.test.ts @@ -0,0 +1,32 @@ +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", + "--preload", + "./src/screens/navigatorTestProfiler.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() })); diff --git a/packages/mobile/src/screens/streaming.behavior.tsx b/packages/mobile/src/screens/streaming.behavior.tsx new file mode 100644 index 00000000000..384abd8b13a --- /dev/null +++ b/packages/mobile/src/screens/streaming.behavior.tsx @@ -0,0 +1,124 @@ +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 "server.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/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/settings.test.ts b/packages/mobile/src/settings.test.ts new file mode 100644 index 00000000000..c41c9c7b6b0 --- /dev/null +++ b/packages/mobile/src/settings.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, test } from "bun:test"; +import { KNOWN_MODELS } from "../../../src/common/constants/knownModels"; +import { + getModelBlockReason, + 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 }, + google: { isConfigured: false, isEnabled: true, apiKeySet: false }, + }, + agents: [], + }; +} + +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(getModelBlockReason(settings, current)).not.toBeNull(); + expect(getModelBlockReason(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(getModelBlockReason(settings, model)).toBeNull(); + settings.config.routeOverrides = { [model]: "direct" }; + expect(modelChoices(settings, "")).not.toContain(model); + expect(getModelBlockReason(settings, model)).not.toBeNull(); + settings.config.routeOverrides = {}; + settings.policy.policy!.providerAccess = [{ id: "openai", allowedModels: ["gpt-4o"] }]; + expect(modelChoices(settings, "")).toContain(model); + expect(getModelBlockReason(settings, model)).toBeNull(); + settings.providers.openai.isEnabled = false; + expect(modelChoices(settings, "")).not.toContain(model); + settings.policy.policy!.providerAccess = [{ id: "anthropic" }]; + expect(getModelBlockReason(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(getModelBlockReason(settings, KNOWN_MODELS.SONNET.id)).toBe( + settings.policy.status.reason! + ); + settings.policy.status.reason = ""; + expect(getModelBlockReason(settings, KNOWN_MODELS.SONNET.id)).toBeTruthy(); + settings.policy = null; + expect(getModelBlockReason(settings, KNOWN_MODELS.SONNET.id)).not.toBeNull(); + settings.policy = { source: "env", status: { state: "enforced" }, policy: null }; + expect(getModelBlockReason(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(getModelBlockReason(settings, KNOWN_MODELS.SONNET.id)).toBeNull(); + settings.policy = { source: "none", status: { state: "disabled" }, policy: null }; + expect(getModelBlockReason(settings, KNOWN_MODELS.SONNET.id)).toBeNull(); + }); +}); + +describe("model action availability", () => { + test.each(["config", "file", "env"] as const)( + "effective %s API key plus OAuth permits non-OAuth models", + (source) => { + const settings = data(); + const model = "openai:gpt-4o"; + settings.providers.openai = { + isEnabled: true, + isConfigured: true, + apiKeySet: false, + apiKeySource: source, + codexOauthSet: true, + models: ["gpt-4o", "gpt-5.3-codex-spark"], + }; + expect(getModelBlockReason(settings, model)).toBeNull(); + expect(modelChoices(settings, "")).toContain(model); + settings.providers.openai.codexOauthSet = false; + expect(getModelBlockReason(settings, "openai:gpt-5.3-codex-spark")).not.toBeNull(); + settings.providers.openai.codexOauthSet = true; + settings.providers.openai.apiKeySource = undefined; + expect(getModelBlockReason(settings, model)).not.toBeNull(); + settings.providers.openai.apiKeySource = "keyless"; + expect(getModelBlockReason(settings, model)).not.toBeNull(); + settings.providers.openrouter = { isEnabled: true, isConfigured: true, apiKeySet: true }; + settings.config.routePriority = ["openrouter", "direct"]; + expect(getModelBlockReason(settings, model)).toBeNull(); + } + ); + + test.each(["disabled", "unconfigured", "removed"])( + "retains the current model but blocks a %s only route without policy", + (state) => { + const settings = data(); + const model = KNOWN_MODELS.SONNET.id; + expect(getModelBlockReason(settings, model)).toBeNull(); + if (state === "disabled") settings.providers.anthropic.isEnabled = false; + if (state === "unconfigured") settings.providers.anthropic.isConfigured = false; + if (state === "removed") delete settings.providers.anthropic; + expect(modelChoices(settings, model)).toContain(model); + expect(getModelBlockReason(settings, model)).not.toBeNull(); + expect(modelChoices(settings, "")).not.toContain(model); + } + ); + + test("catalog removal blocks the only gateway but a valid alternative remains available", () => { + const settings = data(); + const model = "openai:gpt-4o"; + settings.config.routePriority = ["coder", "openrouter", "direct"]; + settings.providers.coder = { + isConfigured: true, + isEnabled: true, + apiKeySet: false, + models: ["openai/gpt-4o"], + discoveredModels: ["openai/gpt-4o"], + }; + expect(getModelBlockReason(settings, model)).toBeNull(); + settings.providers.coder.removedModels = ["openai/gpt-4o"]; + expect(getModelBlockReason(settings, model)).not.toBeNull(); + settings.providers.openrouter = { isConfigured: true, isEnabled: true, apiKeySet: true }; + expect(getModelBlockReason(settings, model)).toBeNull(); + settings.providers.coder.removedModels = []; + delete settings.providers.openrouter; + expect(getModelBlockReason(settings, model)).toBeNull(); + }); + + test.each([ + { + model: "openai:gpt-4o", + apiKeySet: true, + codexOauthSet: false, + isConfigured: true, + allowed: true, + }, + { + model: "openai:gpt-5.3-codex-spark", + apiKeySet: true, + codexOauthSet: false, + isConfigured: true, + allowed: false, + }, + { + model: "openai:gpt-4o", + apiKeySet: false, + codexOauthSet: true, + isConfigured: true, + allowed: false, + }, + { + model: "openai:gpt-5.6-sol", + apiKeySet: false, + codexOauthSet: true, + isConfigured: true, + allowed: true, + }, + { + model: "openai:gpt-4o", + apiKeySet: true, + codexOauthSet: true, + isConfigured: true, + allowed: true, + }, + { + model: "openai:gpt-4o", + apiKeySet: false, + codexOauthSet: false, + isConfigured: false, + allowed: false, + }, + // Configured via environment credentials: apiKeySet only describes the saved key. + { + model: "openai:gpt-4o", + apiKeySet: false, + codexOauthSet: false, + isConfigured: true, + allowed: true, + }, + ])( + "direct auth gates $model with key=$apiKeySet OAuth=$codexOauthSet configured=$isConfigured", + (state) => { + const settings = data(); + settings.providers.openai = { ...state, isEnabled: true, models: [state.model.slice(7)] }; + expect(getModelBlockReason(settings, state.model) === null).toBe(state.allowed); + expect(modelChoices(settings, "").includes(state.model)).toBe(state.allowed); + expect(modelChoices(settings, state.model)).toContain(state.model); + expect( + resolveSettings({}, settings, "exec", { model: state.model, agentId: "exec" }).model + ).toBe(state.model); + // Gateways own their authentication, even when the selected model's direct + // provider has an unsupported auth mode or no credentials at all. + settings.providers.openrouter = { isConfigured: true, isEnabled: true, apiKeySet: true }; + settings.config.routePriority = ["openrouter", "direct"]; + expect(getModelBlockReason(settings, state.model)).toBeNull(); + } + ); +}); + +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("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"; + 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", + allowAgentSetGoal: true, + model: "workspace:exec", + thinkingLevel: "high", + reasoningMode: "standard", + 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"); + 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 new file mode 100644 index 00000000000..066d01b7e97 --- /dev/null +++ b/packages/mobile/src/settings.ts @@ -0,0 +1,216 @@ +import { KNOWN_MODELS, MODEL_ABBREVIATIONS } from "../../../src/common/constants/knownModels"; +import { + isCodexOauthAllowedModel, + 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"; +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" + | "defaultModel" + | "hiddenModels" + | "routePriority" + | "routeOverrides" + | "userPreferences" + >; + providers: Awaited>; + agents: Awaited>; +}; +export type ChatSettings = Pick< + SendMessageOptions, + "model" | "agentId" | "thinkingLevel" | "reasoningMode" | "providerOptions" | "allowAgentSetGoal" +>; +export const thinkingLevels: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh", "max"]; + +export function resolveSettings( + workspace: Pick< + FrontendWorkspaceMetadata, + "aiSettingsByAgent" | "agentId" | "agentType" | "parentWorkspaceId" | "aiSettings" + >, + data: SettingsData, + 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] ?? + (persistedAgentId === agentId ? workspace.aiSettings : undefined); + 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, + // 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, + }; +} + +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 getPolicyStateBlockReason(data: SettingsData): 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."; + return null; +} + +export function getModelBlockReason(data: SettingsData, model: string): string | null { + return getPolicyStateBlockReason(data) ?? getModelRouteBlockReason(data, model); +} + +function getModelRouteBlockReason(data: SettingsData, model: string): string | null { + const { policy, isConfigured, isAccessible } = modelRouting(data); + const route = resolveRoute( + model, + data.config.routePriority ?? ["direct"], + data.config.routeOverrides ?? {}, + isConfigured, + isAccessible + ); + if (!isModelAllowedByPolicy(policy, `${route.routeProvider}:${route.routeModelId}`)) + return "The selected model's route is blocked by server policy. Choose another model."; + // resolveRoute has a direct fallback even when no route is configured. A visible + // current selection must still pass the same availability/auth gates as the picker. + if ( + !isModelAvailable( + model, + data.config.routePriority ?? ["direct"], + data.config.routeOverrides ?? {}, + isConfigured, + isAccessible + ) + ) + return "No configured route can serve the selected model. Choose another model or configure a provider."; + // Gate only the actual direct route; gateways supply their own credentials. + if (route.routeProvider !== "openai") return null; + const openai = data.providers.openai; + // apiKeySet omits env/file credentials; keyless is not API-key authentication. + const hasApiKey = + openai?.apiKeySet === true || + openai?.apiKeySource === "config" || + openai?.apiKeySource === "file" || + openai?.apiKeySource === "env"; + const supported = + hasApiKey && openai?.codexOauthSet + ? true + : !hasApiKey && openai?.codexOauthSet + ? isCodexOauthAllowedModel(model, data.providers) + : !isCodexOauthRequiredModel(model, data.providers); + return supported + ? null + : "The selected model is unavailable with the configured OpenAI authentication. Choose another model or update credentials."; +} + +export function modelChoices(data: SettingsData, currentModel: string): string[] { + const models = new Set(); + if (currentModel) models.add(currentModel); + // 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 || provider === "mux-gateway" || provider === "github-copilot") continue; + for (const entry of config.models ?? []) { + models.add(`${provider}:${typeof entry === "string" ? entry : entry.id}`); + } + } + for (const model of Object.values(KNOWN_MODELS)) models.add(model.id); + const isAuthoritativeModelAccessible = (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 (!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). + return ( + (data.policy?.status.state === "enforced" + ? getModelBlockReason(data, model) + : getModelRouteBlockReason(data, model)) === null + ); + }); +} + +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/packages/mobile/src/streams.test.ts b/packages/mobile/src/streams.test.ts new file mode 100644 index 00000000000..98ecf89b785 --- /dev/null +++ b/packages/mobile/src/streams.test.ts @@ -0,0 +1,270 @@ +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, + useStreamsReconnecting, + wakeStreams, + watch, + watchServerChanges, +} from "./streams"; +import type { MobileClient } from "./api"; +import type { ServerChangeEvent } from "../../../src/common/orpc/schemas/api"; + +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); +}); + +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 new file mode 100644 index 00000000000..59829d8ba6b --- /dev/null +++ b/packages/mobile/src/streams.ts @@ -0,0 +1,194 @@ +import { useSyncExternalStore } from "react"; +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 +// 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>(); +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(); +} + +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 }); + }); +} + +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); + const wakesBefore = wakeGeneration; + 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); + // 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); + } +} + +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/testDom.ts b/packages/mobile/src/testDom.ts new file mode 100644 index 00000000000..bf9fddd1c2f --- /dev/null +++ b/packages/mobile/src/testDom.ts @@ -0,0 +1,11 @@ +import { Window } from "happy-dom"; + +// The mobile React 19 tests must not load the desktop React 18 DOM harness. +const window = new Window(); +Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, +}); diff --git a/packages/mobile/src/theme.ts b/packages/mobile/src/theme.ts new file mode 100644 index 00000000000..7b49a503319 --- /dev/null +++ b/packages/mobile/src/theme.ts @@ -0,0 +1,37 @@ +import { Platform, StyleSheet } from "react-native"; + +import { mobileThemeColors as colors } from "../../../src/common/constants/mobileThemeColors.generated"; + +export { colors }; + +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. +export const fontFamily = Platform.OS === "android" ? "sans-serif" : "System"; +export const typography = StyleSheet.create({ + 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({ + fill: { flex: 1, backgroundColor: colors.background }, + 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.xl, + width: "100%", + maxWidth: 760, + alignSelf: "center", + }, + divider: { height: StyleSheet.hairlineWidth, backgroundColor: colors.border }, + group: { backgroundColor: colors.panel, borderRadius: radii.card, overflow: "hidden" }, +}); diff --git a/packages/mobile/src/transcript.test.ts b/packages/mobile/src/transcript.test.ts new file mode 100644 index 00000000000..3545f748220 --- /dev/null +++ b/packages/mobile/src/transcript.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, test } from "bun:test"; +import { + applyChatEvent, + createTranscriptState, + getVisibleMessages, + 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("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) => { + 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", + 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..df64dc7d01b --- /dev/null +++ b/packages/mobile/src/transcript.ts @@ -0,0 +1,346 @@ +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"; + +export type { MuxMessage, WorkspaceChatMessage }; +export interface TranscriptState { + messages: MuxMessage[]; + streaming: boolean; + streamingMessageId: string | null; + streamingWorkspaceId: string | null; + error: string | null; + caughtUp: boolean; + 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 { + messages: [], + streaming: false, + streamingMessageId: null, + streamingWorkspaceId: null, + error: null, + caughtUp: false, + hasOlderHistory: false, + }; +} + +/** + * 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]; + 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, streamingWorkspaceId: 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, + streamingWorkspaceId: event.workspaceId, + 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, + contextWindowTokens: event.contextWindowTokens, + agentId: event.agentId, + mode: event.mode, + thinkingLevel: event.thinkingLevel, + partial: true, + }, + }), + }; + case "stream-metadata": + if ( + !state.streaming || + state.streamingMessageId !== event.messageId || + state.streamingWorkspaceId !== event.workspaceId + ) + return state; + return updateMessage(state, event.messageId, (message) => ({ + ...message, + metadata: { + ...message.metadata, + ...copyStreamMetadataSnapshot(event.metadata), + }, + })); + 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 "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)); + 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, + ...(event.abortReason === "user" ? { userStopped: true as const } : {}), + }, + })); + 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, streamingWorkspaceId: null } + : {}), + }; + } + case "stream-lifecycle": { + const streaming = + event.phase === "preparing" || event.phase === "streaming" || event.phase === "completing"; + return { + ...state, + streaming, + streamingMessageId: streaming ? state.streamingMessageId : null, + streamingWorkspaceId: streaming ? state.streamingWorkspaceId : 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; + } +} 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/useConnection.test.ts b/packages/mobile/src/useConnection.test.ts new file mode 100644 index 00000000000..7d033d0ce4e --- /dev/null +++ b/packages/mobile/src/useConnection.test.ts @@ -0,0 +1,135 @@ +import "./testDom"; +import { afterEach, describe, expect, test } from "bun:test"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { createORPCClient } from "@orpc/client"; +import type { MobileClient } from "./api"; +import type { Connection } from "./screens/ConnectScreen"; +import { linkedAbortController, useConnection } from "./useConnection"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +function resource(replacement: Promise) { + const closed: boolean[] = []; + const attempts: AbortSignal[] = []; + const connection: Connection = { + endpoint: "https://server.example", + client: createORPCClient({ + call: async () => { + throw new Error("No RPC expected in connection lifecycle test"); + }, + }), + close() { + closed.push(true); + }, + reconnect(options) { + if (!options?.signal) throw new Error("Reconnect must have an abort signal"); + attempts.push(options.signal); + return replacement; + }, + }; + return { connection, closed, attempts }; +} + +afterEach(cleanup); + +describe("mobile connection replacement", () => { + test("aborts old operations immediately and replaces the actual client only after reconnect succeeds", async () => { + const next = deferred(); + const original = resource(next.promise); + const replacement = resource(new Promise(() => {})); + const view = renderHook(() => useConnection(original.connection)); + const oldSignal = view.result.current.signal; + let pending!: Promise; + act(() => { + pending = view.result.current.reconnect(); + }); + expect(oldSignal.aborted).toBe(true); + expect(view.result.current.ready).toBe(false); + expect(view.result.current.connection.client).toBe(original.connection.client); + act(() => { + view.result.current.reconnect(); + }); + expect(original.attempts).toHaveLength(1); + await act(async () => { + next.resolve(replacement.connection); + await pending; + }); + expect(view.result.current.connection.client).toBe(replacement.connection.client); + expect(view.result.current.signal.aborted).toBe(false); + expect(view.result.current.ready).toBe(true); + const activeSignal = view.result.current.signal; + view.unmount(); + expect(activeSignal.aborted).toBe(true); + expect(replacement.closed).toHaveLength(1); + }); + + test("failed reconnect stays read-only and an explicit retry can recover", async () => { + const next = deferred(); + const original = resource(next.promise); + const view = renderHook(() => useConnection(original.connection)); + await act(async () => { + const pending = view.result.current.reconnect(); + next.reject(new Error("offline")); + await pending; + }); + expect(view.result.current.ready).toBe(false); + expect(view.result.current.error).toBe("offline"); + const recovered = resource(new Promise(() => {})); + original.connection.reconnect = () => Promise.resolve(recovered.connection); + await act(async () => { + await view.result.current.reconnect(); + }); + expect(view.result.current.connection).toBe(recovered.connection); + expect(view.result.current.error).toBeNull(); + expect(view.result.current.ready).toBe(true); + }); + + for (const ending of ["disconnect", "unmount"] as const) { + test(`late reconnect cannot resurrect a session after ${ending}`, async () => { + const next = deferred(); + const original = resource(next.promise); + const replacement = resource(new Promise(() => {})); + const view = renderHook(() => useConnection(original.connection)); + let pending!: Promise; + act(() => { + pending = view.result.current.reconnect(); + }); + act(() => { + if (ending === "disconnect") view.result.current.cancel(); + else view.unmount(); + }); + expect(original.attempts[0].aborted).toBe(true); + await act(async () => { + await view.result.current.reconnect(); + }); + expect(original.attempts).toHaveLength(1); + await act(async () => { + next.resolve(replacement.connection); + await pending; + }); + expect(replacement.closed).toHaveLength(1); + expect(view.result.current.connection).toBe(original.connection); + expect(view.result.current.ready).toBe(false); + }); + } +}); + +test("a connection abort cancels linked requests, but a workspace switch only cancels its own request", () => { + const connection = new AbortController(); + const oldWorkspace = linkedAbortController(connection.signal); + const nextWorkspace = linkedAbortController(connection.signal); + oldWorkspace.abort(); + expect(connection.signal.aborted).toBe(false); + expect(nextWorkspace.signal.aborted).toBe(false); + connection.abort(); + expect(nextWorkspace.signal.aborted).toBe(true); + expect(linkedAbortController(connection.signal).signal.aborted).toBe(true); +}); diff --git a/packages/mobile/src/useConnection.ts b/packages/mobile/src/useConnection.ts new file mode 100644 index 00000000000..f68dc3544de --- /dev/null +++ b/packages/mobile/src/useConnection.ts @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState } from "react"; +import type { Connection } from "./screens/ConnectScreen"; + +export function useConnection(initial: Connection) { + const [session, setSession] = useState(() => ({ + connection: initial, + controller: new AbortController(), + })); + const [status, setStatus] = useState<"connected" | "reconnecting" | "disconnected">("connected"); + const [error, setError] = useState(null); + const current = useRef(session); + const attempt = useRef(null); + const ended = useRef(false); + + useEffect(() => { + return () => { + ended.current = true; + attempt.current?.abort(); + current.current.controller.abort(); + current.current.connection.close(); + }; + }, []); + + function cancel() { + ended.current = true; + attempt.current?.abort(); + attempt.current = null; + current.current.controller.abort(); + current.current.connection.close(); + setStatus("disconnected"); + } + + async function reconnect() { + if (ended.current || attempt.current) return; + const controller = new AbortController(); + attempt.current = controller; + current.current.controller.abort(); + current.current.connection.close(); + setStatus("reconnecting"); + setError(null); + try { + const replacement = await current.current.connection.reconnect({ signal: controller.signal }); + // Cancellation wins even if the transport resolves after disconnect/unmount. + if (controller.signal.aborted) { + replacement.close(); + return; + } + current.current = { connection: replacement, controller: new AbortController() }; + setSession(current.current); + setStatus("connected"); + } catch (cause) { + if (!controller.signal.aborted) { + setError(cause instanceof Error ? cause.message : "Could not reconnect to the server."); + setStatus("disconnected"); + } + } finally { + if (attempt.current === controller) attempt.current = null; + } + } + + return { + connection: session.connection, + signal: session.controller.signal, + ready: status === "connected", + reconnecting: status === "reconnecting", + error, + reconnect, + cancel, + }; +} + +export function linkedAbortController(parent: AbortSignal): AbortController { + const controller = new AbortController(); + const abort = () => controller.abort(); + if (parent.aborted) controller.abort(); + else { + parent.addEventListener("abort", abort, { once: true }); + controller.signal.addEventListener("abort", () => parent.removeEventListener("abort", abort), { + once: true, + }); + } + return controller; +} diff --git a/packages/mobile/src/useConversation.test.ts b/packages/mobile/src/useConversation.test.ts new file mode 100644 index 00000000000..09f42ae58a0 --- /dev/null +++ b/packages/mobile/src/useConversation.test.ts @@ -0,0 +1,886 @@ +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"; +import { useConversation } from "./useConversation"; +import { wakeStreams } from "./streams"; +import type { RestoredInput } from "./draft"; +import type { SettingsData } from "./settings"; + +afterEach(cleanup); + +function message( + sequence: number, + text = `message ${sequence}` +): Extract { + return { + type: "message", + id: String(sequence), + role: "assistant", + parts: [{ type: "text", text }], + metadata: { historySequence: sequence }, + }; +} + +type Policy = Awaited>; +const disabledPolicy: Policy = { source: "none", status: { state: "disabled" }, policy: null }; + +function fixture( + getPolicy: () => Promise = async () => disabledPolicy, + reads: { + config?: () => Promise; + agents?: () => Promise; + providers?: () => Promise; + } = {} +) { + type Page = Awaited>; + let complete!: (page: Page) => void; + const page = new Promise((resolve) => { + complete = resolve; + }); + let eventController!: ReadableStreamDefaultController; + const policyRequests: AbortSignal[] = []; + type ChangeEvent = + Awaited> extends AsyncIterable + ? Event + : never; + type Notifier = { + signal: AbortSignal; + events: { enqueue: () => void }; + fail: (error: Error) => void; + }; + // 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 changes(signal: AbortSignal) { + settingsOrder.push("changes.subscribe"); + return new ReadableStream({ + start(controller) { + const close = () => controller.close(); + 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 }); + }, + }).values(); + } + const restored: RestoredInput[] = []; + const chatRequests: AbortSignal[] = []; + const chatInputs: unknown[] = []; + 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 "server.onChanged": + return changes(options.signal!); + case "config.getConfig": + settingsOrder.push("config.read"); + settingsRequests.push({ path: "config", signal: options.signal! }); + return reads.config ? reads.config() : { agentAiDefaults: {} }; + case "providers.getConfig": + settingsOrder.push("providers.read"); + settingsRequests.push({ path: "providers", signal: options.signal! }); + return reads.providers ? reads.providers() : {}; + case "agents.list": + settingsOrder.push("agents.read"); + settingsRequests.push({ path: "agents", signal: options.signal! }); + return reads.agents ? reads.agents() : []; + case "workspace.onChat": + chatRequests.push(options.signal!); + chatInputs.push(input); + return new ReadableStream({ + start(controller) { + eventController = controller; + 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": + requests.push({ input, signal: options.signal }); + return page; + default: + throw new Error(`Unexpected call: ${path.join(".")}`); + } + }, + }); + const lifetime = new AbortController(); + const view = renderHook< + ReturnType, + { + workspaceId: string; + signal: AbortSignal; + onRestore?: (event: RestoredInput) => void; + } + >( + ({ workspaceId, signal, onRestore }) => useConversation(client, workspaceId, signal, onRestore), + { + initialProps: { + workspaceId: "workspace", + signal: lifetime.signal, + onRestore: (event) => restored.push(event), + }, + } + ); + return { + ...view, + complete, + requests, + restored, + chatRequests, + chatInputs, + policyRequests, + policySubscriptions, + configSubscriptions, + providerSubscriptions, + settingsRequests, + settingsOrder, + lifetime, + async ready() { + await waitFor(() => expect(eventController).toBeDefined()); + 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 disconnect() { + await act(async () => eventController.close()); + }, + async emit(event: WorkspaceChatMessage, afterEnqueue?: () => void) { + await act(async () => { + eventController.enqueue(event); + afterEnqueue?.(); + }); + }, + }; +} + +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(); + // 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", + }); + 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(); + const event: RestoredInput = { + type: "restore-to-input", + workspaceId: "workspace", + text: "Follow up", + fileParts: [ + { url: "data:text/plain;base64,dGV4dA==", mediaType: "text/plain", filename: "draft.txt" }, + ], + reviews: [{ filePath: "draft.ts", lineRange: "1", selectedCode: "draft", userNote: "Keep it" }], + }; + await view.emit({ ...event, workspaceId: "other" }); + await view.emit({ + type: "queued-message-changed", + workspaceId: "workspace", + queuedMessages: [event.text], + displayText: event.text, + fileParts: event.fileParts, + reviews: event.reviews, + }); + expect(view.restored).toHaveLength(0); + const transcript = view.result.current.transcript; + await view.emit(event); + expect(view.restored).toEqual([event]); + expect(view.result.current.transcript).toBe(transcript); + const replacement: RestoredInput[] = []; + view.rerender({ + workspaceId: "workspace", + signal: view.lifetime.signal, + onRestore: (value) => replacement.push(value), + }); + expect(replacement).toHaveLength(0); + await view.emit(event); + expect(view.restored).toHaveLength(1); + expect(replacement).toEqual([event]); + expect(view.chatRequests).toHaveLength(1); + await view.emit(event, () => view.lifetime.abort()); + expect(replacement).toHaveLength(1); +}); + +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 () => { + 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; +}); + +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)); + // 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).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 () => { + 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", 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); + 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); +}); + +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); + // 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 = { + ...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("agent catalogs refresh with config/providers and stale catalog success or failure cannot win", async () => { + type Catalog = SettingsData["agents"]; + const enabled: Catalog = [ + { id: "scout", name: "Scout", uiSelectable: true, subagentRunnable: false, scope: "global" }, + ]; + 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; + 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", "agents"] 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: {} }; + }, + agents: async () => { + if (source === "agents" && failed) throw new Error("unavailable"); + return []; + }, + providers: async () => { + if (source === "providers" && failed) throw new Error("unavailable"); + return {}; + }, + }); + await view.ready(); + failed = true; + const subscription = + 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(); + 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.settingsError).toBeNull(); + await act(async () => subscription.fail(new Error("disconnected"))); + await waitFor(() => expect(view.result.current.settings).toBeNull()); + expect(view.result.current.settingsError).toBeNull(); + expect(view.result.current.error).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); + } +); + +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 new file mode 100644 index 00000000000..5a95ea57451 --- /dev/null +++ b/packages/mobile/src/useConversation.ts @@ -0,0 +1,321 @@ +import { useEffect, useRef, useState } from "react"; +import type { MobileClient } from "./api"; +import { + applyChatEvent, + createTranscriptState, + resumeTranscriptState, + type WorkspaceChatMessage, +} from "./transcript"; +import { watch, watchServerChanges } from "./streams"; +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"; + +export function useConversation( + client: MobileClient, + workspaceId: string, + signal: AbortSignal, + onRestore?: (event: RestoredInput) => void +) { + const restore = useRef(onRestore); + useEffect(() => { + restore.current = onRestore; + }, [onRestore]); + const [transcript, setTranscript] = useState(createTranscriptState); + 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); + const displayBatch = useRef<{ take: () => WorkspaceChatMessage[] } | null>(null); + const historyRequest = useRef(null); + type HistoryCursor = NonNullable< + Parameters[0]["cursor"] + >; + const historyCursor = useRef(null); + useEffect(() => { + const controller = linkedAbortController(signal); + setTranscript(createTranscriptState()); + setSettings(null); + setPolicy(null); + setError(null); + setSettingsError(null); + setOwner({ client, workspaceId, signal }); + setLoadingOlder(false); + 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 } + ); + + 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()); + } + let settingsRequest: AbortController | null = null; + function refreshSettings() { + settingsRequest?.abort(); + 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. + 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()); + } + function settingsUnavailable() { + settingsRequest?.abort(); + setSettings(null); + setSettingsError("Settings unavailable. Retry to reconnect."); + } + // 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: () => { + // 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); + setSettings(null); + }, + }).catch(() => { + if (controller.signal.aborted) return; + setPolicy(null); + settingsUnavailable(); + }); + 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 stream. + if (event.type === "restore-to-input") { + flush(); + if (event.workspaceId === workspaceId) restore.current?.(event); + 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); + } + 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); + }, + 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("The server rejected this session. Retry to reconnect or sign in again."); + }); + return () => { + controller.abort(); + historyRequest.current?.abort(); + historyRequest.current = null; + }; + }, [client, workspaceId, signal]); + const owned = + owner.client === client && + owner.workspaceId === workspaceId && + owner.signal === signal && + !signal.aborted; + async function loadOlder() { + if ( + !owned || + 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; + 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 = queuedDeltas.reduce(applyChatEvent, 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 { + transcript: owned ? transcript : createTranscriptState(), + settings: owned && settings ? { ...settings, policy } : null, + error: owned ? error : null, + settingsError: owned ? settingsError : null, + loadingOlder, + historyError, + loadOlder, + }; +} diff --git a/packages/mobile/src/useProjects.test.ts b/packages/mobile/src/useProjects.test.ts new file mode 100644 index 00000000000..4bf5aaede8e --- /dev/null +++ b/packages/mobile/src/useProjects.test.ts @@ -0,0 +1,322 @@ +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"; +import { wakeStreams } from "./streams"; + +afterEach(cleanup); + +type ChangeEvent = + 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 changes: 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 "server.onChanged": + return subscribe(changes, 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, changes }; +} +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[0]).toBe("server.onChanged"); + 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.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.changes).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.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.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.changes[0].emit({ type: "config" })); + await waitFor(() => expect(source.projectReads).toHaveLength(3)); + 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"))); + 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.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.changes[0].emit({ type: "metadata", 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.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.changes[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.changes[1].emit({ type: "config" })); + 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(["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; + await act(async () => source.changes[0][ending]()); + expect(view.result.current.error).toBeNull(); + expect(view.result.current.projects).toEqual(catalog("initial")); + expect(view.result.current.workspaces).toEqual([workspace]); + act(() => wakeStreams()); + 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 () => { + source.projectReads[1].resolve(catalog("healed")); + source.workspaceReads[1].resolve([{ ...workspace, name: "healed" }]); + }); + expect(view.result.current.error).toBeNull(); + expect(view.result.current.projects).toEqual(catalog("healed")); + expect(view.result.current.workspaces).toEqual([{ ...workspace, name: "healed" }]); + } +); + +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; + 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")); + // 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.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(); + expect(view.result.current.projects).toEqual(catalog("healed")); +}); + +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).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 () => { + 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]); +}); + +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.changes[0].end()); + act(() => wakeStreams()); + await waitFor(() => expect(source.workspaceReads).toHaveLength(2)); + await act(async () => + 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"))); + 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 new file mode 100644 index 00000000000..d5c74f155dd --- /dev/null +++ b/packages/mobile/src/useProjects.ts @@ -0,0 +1,141 @@ +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 { watchServerChanges } from "./streams"; + +export type Projects = Awaited>; +type MetadataEvent = Extract; + +export function useProjects(client: MobileClient, signal: AbortSignal) { + 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 = linkedAbortController(signal); + setLoading(true); + setError(null); + if (signal.aborted) { + setLoading(false); + return; + } + 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()); + }; + } + 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; + }); + } + // 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; + } + const refreshWorkspaces = reader( + (signal) => client.workspace.list(undefined, { signal }), + (workspaceList) => { + setWorkspaces(workspaceList); + loaded.workspaces = true; + releaseMetadata(); + }, + releaseMetadata + ); + // 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. + 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); + controller.abort(); + }); + return () => controller.abort(); + }, [client, signal, generation]); + 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 new file mode 100644 index 00000000000..cd775b61de3 --- /dev/null +++ b/packages/mobile/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "noEmit": true, + "types": ["react", "bun"], + "moduleSuffixes": [".native", ""], + "paths": { + "@/*": ["../../src/*"], + "@shared/*": ["../../src/*"] + } + }, + "include": [ + "App.tsx", + "index.ts", + "src/**/*.ts", + "src/**/*.tsx", + "scripts/**/*.ts", + "e2e/**/*.ts", + "playwright.config.ts" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/src/browser/contexts/API.test.tsx b/src/browser/contexts/API.test.tsx index 8247ece4933..2d1478ae0c0 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,347 @@ 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([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 ( + (input instanceof Request ? input.url : input.toString()).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 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"; + 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 probe anonymously once before requiring auth", 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"); + let mintFailures = 0; + fetchImpl = (input) => { + if ( + (input instanceof Request ? input.url : input.toString()).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(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 cf4e05e096d..a2a373d7d01 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 }); @@ -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); @@ -219,7 +221,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 +267,265 @@ 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) { - authRequiredRef.current = true; + if (error instanceof WebSocketTicketError && error.reason === "authentication") { clearStoredAuthToken(); - hasConnectedRef.current = false; // Reset - need fresh auth - setState({ status: "auth_required", error: "Authentication required" }); - return; - } - - if (authRequiredRef.current) { - return; + 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; + authProbeAttemptedRef.current = false; + reconnectAttemptRef.current = 0; + scheduleReconnectRef.current?.(); + } 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] ); @@ -484,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; @@ -494,6 +560,7 @@ function ManagedAPIProvider(props: Omit) { useEffect(() => { connect(authToken); return () => { + connectionIdRef.current += 1; cleanupRef.current?.(); if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); @@ -523,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; }; @@ -628,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); @@ -642,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 => { 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/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/browser/features/Tools/Shared/ToolPrimitives.test.tsx b/src/browser/features/Tools/Shared/ToolPrimitives.test.tsx new file mode 100644 index 00000000000..ae49a7633fc --- /dev/null +++ b/src/browser/features/Tools/Shared/ToolPrimitives.test.tsx @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + AlertTriangle, + Globe, + MessageCircleQuestion, + Pencil, + RefreshCw, + Sparkles, + Wrench, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; +import { TOOL_NAME_TO_ICON, ToolIcon } from "./ToolPrimitives"; + +function glyph(markup: string): string { + const match = /]*>([\s\S]*?)<\/svg>/.exec(markup); + expect(match).not.toBeNull(); + return match![1]; +} + +const cases: Array<[string, LucideIcon]> = [ + ["bash", Wrench], + ["ask_user_question", MessageCircleQuestion], + ["file_edit_insert", Pencil], + ["server:GOOGLE_SEARCH_WEB", Globe], + ["mcp__custom__search", Sparkles], + ["unknown_tool", Sparkles], + ["constructor", Sparkles], + ["__proto__", Sparkles], +]; + +test.each(cases)("desktop tool %s preserves its actual SVG glyph", (toolName, Expected) => { + const markup = renderToStaticMarkup( + + + + ); + expect(glyph(markup)).toBe(glyph(renderToStaticMarkup())); +}); + +test("desktop emoji overrides preserve normalization, fallback, and spin control", () => { + for (const [emoji, Expected] of [ + ["⚠️", AlertTriangle], + ["🔄", RefreshCw], + ["unmapped", Sparkles], + ] as const) { + const renderIcon = (spin?: boolean) => + renderToStaticMarkup( + + + + ); + expect(glyph(renderIcon())).toBe(glyph(renderToStaticMarkup())); + expect(renderIcon().includes("animate-spin")).toBe(emoji === "🔄"); + expect(renderIcon(false).includes("animate-spin")).toBe(false); + } +}); + +test("generic desktop cards retain the exported named-tool component registry", () => { + expect(TOOL_NAME_TO_ICON.file_edit_replace_string).toBe(Pencil); + expect(TOOL_NAME_TO_ICON["server:GOOGLE_SEARCH_WEB"]).toBe(Globe); + expect(Object.hasOwn(TOOL_NAME_TO_ICON, "unknown_tool")).toBe(false); +}); diff --git a/src/browser/features/Tools/Shared/ToolPrimitives.tsx b/src/browser/features/Tools/Shared/ToolPrimitives.tsx index 3ff83a95611..863b01f87bc 100644 --- a/src/browser/features/Tools/Shared/ToolPrimitives.tsx +++ b/src/browser/features/Tools/Shared/ToolPrimitives.tsx @@ -1,5 +1,6 @@ import React from "react"; import { cn } from "@/common/lib/utils"; +import { getToolIconName, TOOL_ICON_NAMES, type ToolIconName } from "@/common/constants/toolIcons"; import { TranscriptIgnoreContextMenu, TranscriptQuoteRoot, @@ -241,73 +242,49 @@ interface ToolIconProps { className?: string; } -export const TOOL_NAME_TO_ICON: Partial> = { - bash: Wrench, - bash_output: Wrench, - bash_background_terminate: Square, - bash_background_list: List, - workflow_run: Sparkles, - workflow_resume: RotateCcw, - agent_report: FileText, - agent_skill_read: GraduationCap, - agent_skill_read_file: GraduationCap, - // LayoutGrid (a 2×2 catalog of tiles) reads as "the index of skills you can pick - // from" — distinct from the GraduationCap used when reading a single skill. - agent_skill_list: LayoutGrid, - advisor: Lightbulb, - ask_user_question: MessageCircleQuestion, - file_read: BookOpen, - session_history: History, - memory: Brain, - intuition: BrainCircuit, - attach_file: Paperclip, - desktop_screenshot: Monitor, - desktop_move_mouse: Move, - desktop_click: MousePointerClick, - desktop_double_click: MousePointerClick, - desktop_drag: Hand, - desktop_scroll: ArrowDownUp, - desktop_type: Keyboard, - desktop_key_press: Keyboard, - file_edit_insert: Pencil, - file_edit_replace_string: Pencil, - file_edit_replace_lines: Pencil, - todo_write: List, - web_fetch: Globe, - web_search: Globe, - "server:GOOGLE_SEARCH_WEB": Globe, - notify: Bell, - tool_catalog_search: Search, - tool_search: Search, - review_pane_update: Sparkles, - review_pane_get: ScanEye, - analytics_query: Database, - task_retitle: Pencil, - task_stop: Square, - task_remove: Trash2, - task_send_message: MessageSquareMore, - task_apply_git_patch: GitCommit, - // Layers (stacked planes) reads as "manage the stack of child workspaces" — matches the - // WorkspaceLifecycleToolCall card's glyph. - task_workspace_lifecycle: Layers, - set_goal: Target, - get_goal: Target, - complete_goal: CircleCheck, - // Activity (ECG line) rather than HeartPulse: it matches the scanning pulse-trace - // motif inside the HeartbeatToolCall card. The config menu uses HeartPulse. - heartbeat: Activity, - // Sparkles matches the icon the Timeline tab feed renders on agent.event rows, which is - // exactly where a timeline_event call lands. - timeline_event: Sparkles, +const toolIcons: Record = { + Activity, + ArrowDownUp, + Bell, + BookOpen, + Brain, + BrainCircuit, + CircleCheck, + Database, + FileText, + GitCommit, + Globe, + GraduationCap, + Hand, + History, + Keyboard, + Layers, + LayoutGrid, + Lightbulb, + List, + MessageCircleQuestion, + MessageSquareMore, + Monitor, + MousePointerClick, + Move, + Paperclip, + Pencil, + RotateCcw, + ScanEye, + Search, + Sparkles, + Square, + Target, + Trash2, + Wrench, }; +export const TOOL_NAME_TO_ICON: Partial> = Object.fromEntries( + Object.entries(TOOL_ICON_NAMES).map(([toolName, icon]) => [toolName, toolIcons[icon]]) +); + export const ToolIcon: React.FC = ({ toolName, emoji, emojiSpin, className }) => { - // Object.hasOwn: toolName comes from persisted transcripts; a bare index lookup returns - // inherited Object members for names like "constructor", which would render garbage - // instead of falling back to Sparkles. - const Icon = Object.hasOwn(TOOL_NAME_TO_ICON, toolName) - ? (TOOL_NAME_TO_ICON[toolName] ?? Sparkles) - : Sparkles; + const Icon = toolIcons[getToolIconName(toolName)]; return ( diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index a22950434b9..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); @@ -1541,6 +1666,56 @@ describe("WorkspaceStore", () => { const usage = store.getWorkspaceUsage(workspaceId); expect(usage.liveMetadataModel).toBe("anthropic:claude-sonnet-4-20250514"); expect(usage.liveCostUsage?.input.cost_usd).toBeGreaterThan(0); + // Dispatch below replay buffering because this fixture seeded the active aggregator directly. + const internal = getInternal<{ + processStreamEvent: ( + id: string, + target: typeof aggregator, + event: WorkspaceChatMessage + ) => void; + }>(store); + const dispatch = (event: WorkspaceChatMessage) => + internal.processStreamEvent(workspaceId, aggregator, event); + if (!usage.liveCostUsage) throw new Error("Expected refused attempt usage"); + const refusedModel = "anthropic:claude-sonnet-4-20250514"; + dispatch({ + type: "session-usage-delta", + workspaceId, + sourceWorkspaceId: workspaceId, + byModelDelta: { [refusedModel]: usage.liveCostUsage }, + timestamp: 2_000, + }); + const ledgerBefore = structuredClone(store.getWorkspaceUsage(workspaceId).sessionByModel); + expect(ledgerBefore?.[refusedModel].input.tokens).toBe(1000); + dispatch({ + type: "stream-metadata", + workspaceId, + messageId: "msg-live-coder", + metadata: { + model: "openai:gpt-4o", + metadataModel: "openai:gpt-4o", + contextWindowTokens: 128_000, + routedThroughGateway: false, + routeProvider: null, + }, + }); + const afterFallback = store.getWorkspaceUsage(workspaceId); + expect(afterFallback.liveMetadataModel).toBe("openai:gpt-4o"); + expect(afterFallback.liveUsage).toBeUndefined(); + expect(afterFallback.liveCostUsage).toBeUndefined(); + expect(afterFallback.sessionByModel).toEqual(ledgerBefore); + dispatch({ + type: "usage-delta", + workspaceId, + messageId: "msg-live-coder", + usage: { inputTokens: 2000, outputTokens: 200, totalTokens: 2200 }, + cumulativeUsage: { inputTokens: 2000, outputTokens: 200, totalTokens: 2200 }, + }); + const fresh = store.getWorkspaceUsage(workspaceId); + expect(fresh.liveMetadataModel).toBe("openai:gpt-4o"); + expect(fresh.liveCostUsage?.input.tokens).toBe(2000); + expect(fresh.liveCostUsage?.input.cost_usd).toBeGreaterThan(0); + expect(fresh.sessionByModel).toEqual(ledgerBefore); }); }); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index ee4cedc35b7..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; } /** @@ -910,6 +912,11 @@ export class WorkspaceStore { // fresh recompute regardless of how the previous one was wound down. this.streamingStatsStore.bump(workspaceId); }, + "stream-metadata": (workspaceId, aggregator, data) => { + applyWorkspaceChatEventToAggregator(aggregator, data); + this.states.bump(workspaceId); + this.usageStore.bump(workspaceId); + }, "stream-lifecycle": (workspaceId, aggregator, data) => { applyWorkspaceChatEventToAggregator(aggregator, data); this.states.bump(workspaceId); @@ -2867,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; @@ -2908,6 +2916,7 @@ export class WorkspaceStore { liveUsage, liveCostUsage, liveMetadataModel, + liveContextWindowTokens, }; }); } @@ -4301,6 +4310,7 @@ export class WorkspaceStore { return ( data.type === "stream-start" || + data.type === "stream-metadata" || data.type === "stream-end" || data.type === "stream-abort" || data.type === "stream-error" diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 60a12ab1269..44bf421f184 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -331,6 +331,8 @@ --color-toast-fatal-border: hsl(0 36% 26%); /* #5a2c2c - fatal error border */ /* Semi-transparent overlays */ + /* Shared modal scrim, projected into the native companion's backdrop. */ + --color-overlay-scrim: hsl(240 3% 3% / 0.58); --color-danger-overlay: hsl(4 90% 58% / 0.1); /* danger with 10% opacity */ --color-warning-overlay: hsl(45 100% 51% / 0.1); /* warning with 10% opacity */ --color-gray-overlay: hsl(0 0% 39% / 0.05); /* gray with 5% opacity */ diff --git a/src/browser/utils/messages/StreamingMessageAggregator.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.test.ts index dcaa4070817..8b9da61363f 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.test.ts @@ -8,6 +8,8 @@ import { getInterruptionContext } from "@/common/utils/messages/retryEligibility import { shouldNotifyOnResponseComplete } from "./responseCompletionMetadata"; import { MAX_HISTORY_HIDDEN_SEGMENTS } from "./transcriptTruncationPlan"; import { StreamingMessageAggregator } from "./StreamingMessageAggregator"; +import { applyWorkspaceChatEventToAggregator } from "./applyWorkspaceChatEventToAggregator"; +import type { StreamMetadataEvent } from "@/common/types/stream"; // Test helper: create aggregator with default createdAt for tests const TEST_CREATED_AT = "2024-01-01T00:00:00.000Z"; @@ -4709,3 +4711,94 @@ describe("notify tool -> browser notifications", () => { }); }); }); + +test("fallback metadata resets request usage while preserving streamed content and identity", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT, TEST_WORKSPACE_ID); + startTestStream(aggregator); + aggregator.handleStreamDelta({ + type: "stream-delta", + workspaceId: TEST_WORKSPACE_ID, + messageId: "msg1", + delta: "keep text", + tokens: 2, + timestamp: 10, + }); + const usage = { inputTokens: 1000, outputTokens: 2, totalTokens: 1002 }; + const original = aggregator.getAllMessages()[0]; + const event: StreamMetadataEvent = { + type: "stream-metadata", + workspaceId: TEST_WORKSPACE_ID, + messageId: "msg1", + metadata: { + model: "openai:gpt-4o", + metadataModel: "openai:gpt-4o", + contextWindowTokens: 400_000, + routedThroughGateway: false, + routeProvider: null, + }, + }; + applyWorkspaceChatEventToAggregator(aggregator, { + ...event, + metadata: { + ...event.metadata, + model: TEST_MODEL, + thinkingLevel: "high", + routedThroughGateway: true, + routeProvider: "coder", + modelFallback: { requestedModel: TEST_MODEL, refusedModels: [TEST_MODEL] }, + }, + }); + aggregator.handleUsageDelta({ + type: "usage-delta", + workspaceId: TEST_WORKSPACE_ID, + messageId: "msg1", + usage, + cumulativeUsage: usage, + }); + original.metadata = { + ...original.metadata, + usage, + contextUsage: usage, + providerMetadata: { anthropic: { cached: true } }, + contextProviderMetadata: { anthropic: { cached: true } }, + }; + applyWorkspaceChatEventToAggregator(aggregator, { ...event, workspaceId: "different" }); + applyWorkspaceChatEventToAggregator(aggregator, { ...event, messageId: "different" }); + expect(aggregator.getCurrentModel()).toBe(TEST_MODEL); + expect(aggregator.getActiveStreamUsage("msg1")).toBe(usage); + expect(applyWorkspaceChatEventToAggregator(aggregator, event)).toBe("immediate"); + expect(aggregator.getCurrentModel()).toBe("openai:gpt-4o"); + expect(aggregator.getActiveStreamMetadataModel()).toBe("openai:gpt-4o"); + expect(aggregator.getAllMessages()[0].parts).toBe(original.parts); + expect(aggregator.getAllMessages()[0].metadata?.contextWindowTokens).toBe(400_000); + expect(aggregator.getAllMessages()[0].metadata?.thinkingLevel).toBeUndefined(); + expect(aggregator.getAllMessages()[0].metadata?.routeProvider).toBeUndefined(); + expect(aggregator.getAllMessages()[0].metadata?.modelFallback).toBeUndefined(); + + expect(aggregator.getActiveStreamUsage("msg1")).toBeUndefined(); + expect(aggregator.getActiveStreamCumulativeUsage("msg1")).toBeUndefined(); + for (const key of [ + "usage", + "contextUsage", + "providerMetadata", + "contextProviderMetadata", + ] as const) { + expect(aggregator.getAllMessages()[0].metadata?.[key]).toBeUndefined(); + } + const freshUsage = { inputTokens: 2000, outputTokens: 3, totalTokens: 2003 }; + aggregator.handleUsageDelta({ + type: "usage-delta", + workspaceId: TEST_WORKSPACE_ID, + messageId: "msg1", + usage: freshUsage, + cumulativeUsage: freshUsage, + }); + expect(aggregator.getActiveStreamUsage("msg1")).toBe(freshUsage); + expect(aggregator.hasInterruptibleActiveStream()).toBe(true); + applyWorkspaceChatEventToAggregator(aggregator, { + ...event, + metadata: { ...event.metadata, contextWindowTokens: null }, + }); + expect(aggregator.getAllMessages()[0].metadata?.contextWindowTokens).toBeNull(); + expect(aggregator.getActiveStreamUsage("msg1")).toBeUndefined(); +}); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 7e91e314ac7..e3c34d951df 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -16,7 +16,9 @@ import { import { copyStreamLifecycleSnapshot, + copyStreamMetadataSnapshot, type StreamStartEvent, + type StreamMetadataEvent, type StreamDeltaEvent, type UsageDeltaEvent, type StreamEndEvent, @@ -249,6 +251,7 @@ interface StreamingContext { * stream is active. */ metadataModel?: string; + contextWindowTokens?: number | null; routedThroughGateway?: boolean; routeProvider?: string; @@ -1933,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) { @@ -2105,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, @@ -2173,6 +2181,29 @@ export class StreamingMessageAggregator { this.markMessageDirty(data.messageId); } + handleStreamMetadata(data: StreamMetadataEvent): void { + const context = this.activeStreams.get(data.messageId); + const message = this.messages.get(data.messageId); + if ( + !context || + context.isComplete || + !message || + this.getActiveStreamEntry()?.[0] !== data.messageId || + (this.workspaceId !== undefined && this.workspaceId !== data.workspaceId) + ) + return; + this.activeStreamUsage.delete(data.messageId); + 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; + message.metadata = { ...message.metadata, ...metadata }; + this.markMessageDirty(data.messageId); + } + handleStreamDelta(data: StreamDeltaEvent): void { const message = this.messages.get(data.messageId); if (!message) return; diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts index cfa187aa910..1dec68cf0c0 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts @@ -10,6 +10,7 @@ import type { StreamDeltaEvent, StreamEndEvent, StreamStartEvent, + StreamMetadataEvent, ToolCallDeltaEvent, ToolCallEndEvent, ToolCallExecutionStartEvent, @@ -29,6 +30,10 @@ class StubAggregator implements WorkspaceChatEventAggregator { this.calls.push(`handleStreamStart:${data.messageId}`); } + handleStreamMetadata(data: StreamMetadataEvent): void { + this.calls.push(`handleStreamMetadata:${data.messageId}`); + } + handleStreamDelta(data: StreamDeltaEvent): void { this.calls.push(`handleStreamDelta:${data.messageId}`); } diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts index e2bc2aeb855..04c74ad53c0 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts @@ -23,6 +23,7 @@ import { isStreamEnd, isStreamError, isStreamStart, + isStreamMetadata, isToolCallDelta, isToolCallEnd, isToolCallExecutionStart, @@ -36,6 +37,7 @@ import type { StreamDeltaEvent, StreamEndEvent, StreamStartEvent, + StreamMetadataEvent, ToolCallDeltaEvent, ToolCallEndEvent, ToolCallExecutionStartEvent, @@ -64,6 +66,7 @@ export interface ApplyWorkspaceChatEventToAggregatorOptions { */ export interface WorkspaceChatEventAggregator { handleStreamStart(data: StreamStartEvent): void; + handleStreamMetadata(data: StreamMetadataEvent): void; handleStreamDelta(data: StreamDeltaEvent): void; handleStreamEnd(data: StreamEndEvent): void; handleStreamAbort(data: StreamAbortEvent): void; @@ -126,6 +129,11 @@ export function applyWorkspaceChatEventToAggregator( return "immediate"; } + if (isStreamMetadata(event)) { + aggregator.handleStreamMetadata(event); + return "immediate"; + } + if (isStreamDelta(event)) { aggregator.handleStreamDelta(event); return "throttled"; diff --git a/src/browser/utils/messages/displayedMessageBuilder.ts b/src/browser/utils/messages/displayedMessageBuilder.ts index b6be56f1a31..ba94cb9b391 100644 --- a/src/browser/utils/messages/displayedMessageBuilder.ts +++ b/src/browser/utils/messages/displayedMessageBuilder.ts @@ -31,6 +31,10 @@ 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"; +import { getNestedCallsForDisplay } from "@/common/utils/messages/nestedToolCalls"; + +export { mergeAdjacentParts } from "@/common/utils/messages/mergeAdjacentParts"; /** * Check if a tool result indicates success (for tools that return { success: boolean }) @@ -80,66 +84,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) { @@ -190,7 +134,6 @@ export interface BuildDisplayedMessagesForMessageOptions { } type ToolDisplayStatus = Extract["status"]; -type NestedToolCalls = NonNullable; function buildPlanDisplayMessages( message: MuxMessage, @@ -547,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/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index 1c09bba4fa9..d967fb90637 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -3,8 +3,8 @@ import { parseStagedAttachmentNotice, } from "@/browser/features/ChatInput/stagedAttachments"; import type { StagedChatAttachment } from "@/browser/features/ChatInput/ChatAttachments"; -import type { DisplayedMessage, ReviewNoteDataForDisplay } from "@/common/types/message"; -import { formatReviewForModel } from "@/common/types/review"; +import type { DisplayedMessage } from "@/common/types/message"; +import { stripRenderedReviews } from "@/common/types/review"; import type { BashOutputToolArgs } from "@/common/types/tools"; export interface EditableUserMessageDraftContent { @@ -28,27 +28,6 @@ export function getEditableUserMessageDraftContent( }; } -function stripRenderedReviews(content: string, reviews?: ReviewNoteDataForDisplay[]): string { - if (!reviews || reviews.length === 0) { - return content; - } - - // Reviews are already stored in metadata; strip their rendered tags to avoid duplication on edit. - const reviewText = reviews.map(formatReviewForModel).join("\n\n"); - if (!content.startsWith(reviewText)) { - return content; - } - - const remainder = content.slice(reviewText.length); - if (remainder.startsWith("\n\n")) { - return remainder.slice(2); - } - if (remainder.startsWith("\n")) { - return remainder.slice(1); - } - return remainder; -} - /** * Type guard to check if a message is a bash_output tool call with valid args */ diff --git a/src/common/constants/mobileThemeColors.generated.ts b/src/common/constants/mobileThemeColors.generated.ts new file mode 100644 index 00000000000..60241537987 --- /dev/null +++ b/src/common/constants/mobileThemeColors.generated.ts @@ -0,0 +1,24 @@ +// Generated from globals.css by packages/mobile/scripts/generateTheme.cjs. +// Run the generator with --write to refresh; do not edit this palette manually. +export const mobileThemeColors = { + background: "rgba(31, 31, 31, 1)", + panel: "rgba(39, 39, 42, 1)", + elevated: "rgba(54, 54, 58, 1)", + border: "rgba(69, 69, 69, 1)", + text: "rgba(230, 230, 230, 1)", + bright: "rgba(255, 255, 255, 1)", + muted: "rgba(161, 161, 170, 1)", + dim: "rgba(107, 107, 107, 1)", + sheet: "rgba(22, 22, 22, 1)", + selection: "rgba(116, 173, 231, 1)", + accent: "rgba(170, 100, 247, 1)", + accentSurface: "rgba(170, 100, 247, 0.12)", + plan: "rgba(116, 173, 231, 1)", + danger: "rgba(248, 114, 114, 1)", + dangerSurface: "rgba(248, 114, 114, 0.1)", + warning: "rgba(237, 185, 94, 1)", + warningSurface: "rgba(237, 185, 94, 0.1)", + success: "rgba(22, 162, 73, 1)", + user: "rgba(255, 255, 255, 0.06)", + scrim: "rgba(7, 7, 8, 0.58)", +} as const; diff --git a/src/common/constants/toolIcons.ts b/src/common/constants/toolIcons.ts new file mode 100644 index 00000000000..c012436d0b3 --- /dev/null +++ b/src/common/constants/toolIcons.ts @@ -0,0 +1,70 @@ +// Tool semantics are shared by the desktop and native icon registries, without +// importing either renderer's React or SVG implementation. +export const TOOL_ICON_NAMES = { + bash: "Wrench", + bash_output: "Wrench", + bash_background_terminate: "Square", + bash_background_list: "List", + workflow_run: "Sparkles", + workflow_resume: "RotateCcw", + agent_report: "FileText", + agent_skill_read: "GraduationCap", + agent_skill_read_file: "GraduationCap", + // LayoutGrid (a 2×2 catalog of tiles) reads as "the index of skills you can pick + // from" — distinct from the GraduationCap used when reading a single skill. + agent_skill_list: "LayoutGrid", + advisor: "Lightbulb", + ask_user_question: "MessageCircleQuestion", + file_read: "BookOpen", + session_history: "History", + memory: "Brain", + intuition: "BrainCircuit", + attach_file: "Paperclip", + desktop_screenshot: "Monitor", + desktop_move_mouse: "Move", + desktop_click: "MousePointerClick", + desktop_double_click: "MousePointerClick", + desktop_drag: "Hand", + desktop_scroll: "ArrowDownUp", + desktop_type: "Keyboard", + desktop_key_press: "Keyboard", + file_edit_insert: "Pencil", + file_edit_replace_string: "Pencil", + file_edit_replace_lines: "Pencil", + todo_write: "List", + web_fetch: "Globe", + web_search: "Globe", + "server:GOOGLE_SEARCH_WEB": "Globe", + notify: "Bell", + tool_catalog_search: "Search", + tool_search: "Search", + review_pane_update: "Sparkles", + review_pane_get: "ScanEye", + analytics_query: "Database", + task_retitle: "Pencil", + task_stop: "Square", + task_remove: "Trash2", + task_send_message: "MessageSquareMore", + task_apply_git_patch: "GitCommit", + // Layers (stacked planes) reads as "manage the stack of child workspaces" — matches the + // WorkspaceLifecycleToolCall card's glyph. + task_workspace_lifecycle: "Layers", + set_goal: "Target", + get_goal: "Target", + complete_goal: "CircleCheck", + // Activity (ECG line) rather than HeartPulse: it matches the scanning pulse-trace + // motif inside the HeartbeatToolCall card. The config menu uses HeartPulse. + heartbeat: "Activity", + // Sparkles matches the icon the Timeline tab feed renders on agent.event rows, which is + // exactly where a timeline_event call lands. + timeline_event: "Sparkles", +} as const; + +export type ToolIconName = (typeof TOOL_ICON_NAMES)[keyof typeof TOOL_ICON_NAMES]; + +export function getToolIconName(toolName: string): ToolIconName { + // Persisted names such as "constructor" must not resolve to inherited Object members. + return Object.hasOwn(TOOL_ICON_NAMES, toolName) + ? TOOL_ICON_NAMES[toolName as keyof typeof TOOL_ICON_NAMES] + : "Sparkles"; +} 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.ts b/src/common/orpc/schemas.ts index fa3c732a04f..92ad9acef32 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -138,6 +138,7 @@ export { StreamEndEventSchema, StreamErrorMessageSchema, StreamStartEventSchema, + StreamMetadataEventSchema, ToolCallDeltaEventSchema, ToolCallEndEventSchema, ToolCallExecutionStartEventSchema, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 0e6184477d8..37285dee902 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, @@ -189,6 +190,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(), @@ -1506,6 +1519,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(), @@ -2455,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(), @@ -2480,9 +2509,29 @@ 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 = { + 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/schemas/message.test.ts b/src/common/orpc/schemas/message.test.ts index 31c4682bc08..6bbabb2e998 100644 --- a/src/common/orpc/schemas/message.test.ts +++ b/src/common/orpc/schemas/message.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { MuxMessageSchema } from "./message"; +import { StreamStartEventSchema } from "./stream"; function createMessage() { return { @@ -187,3 +188,30 @@ describe("MuxMessageSchema compactionEpoch parsing", () => { } }); }); + +test("request capacity preserves explicit unknown versus legacy absence and sanitizes corrupt history", () => { + const start = { + type: "stream-start", + workspaceId: "w", + messageId: "m", + model: "local:test", + historySequence: 0, + startTime: 0, + }; + for (const contextWindowTokens of [200_000, null, undefined]) { + expect( + StreamStartEventSchema.parse({ ...start, contextWindowTokens }).contextWindowTokens + ).toBe(contextWindowTokens); + expect( + MuxMessageSchema.parse({ ...createMessage(), metadata: { contextWindowTokens } }).metadata + ?.contextWindowTokens + ).toBe(contextWindowTokens); + } + for (const contextWindowTokens of [-1, 0, 1.5, "unknown"]) { + expect(StreamStartEventSchema.safeParse({ ...start, contextWindowTokens }).success).toBe(false); + expect( + MuxMessageSchema.parse({ ...createMessage(), metadata: { contextWindowTokens } }).metadata + ?.contextWindowTokens + ).toBeUndefined(); + } +}); diff --git a/src/common/orpc/schemas/message.ts b/src/common/orpc/schemas/message.ts index 15513f36a59..393eb2737a5 100644 --- a/src/common/orpc/schemas/message.ts +++ b/src/common/orpc/schemas/message.ts @@ -6,6 +6,11 @@ import { StreamErrorTypeSchema } from "./errors"; import { AgentSkillScopeSchema, SkillNameSchema } from "./agentSkill"; import { WorkflowRunIdSchema, WorkflowRunRecordSchema } from "./workflow"; +export const ContextWindowTokensSchema = z.number().int().positive().nullable().meta({ + description: + "Request-pinned effective context capacity; null means unknown, absent means legacy metadata", +}); + export const FilePartSchema = z.object({ url: z.string(), mediaType: z.string(), @@ -166,6 +171,7 @@ export const MuxMessageSchema = z.object({ timestamp: z.number().optional(), model: z.string().optional(), metadataModel: z.string().optional(), + contextWindowTokens: ContextWindowTokensSchema.optional().catch(undefined), thinkingLevel: ThinkingLevelSchema.optional(), routedThroughGateway: z.boolean().optional(), routeProvider: z.string().optional(), // Preserve replayed/non-stream route attribution. @@ -199,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/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 271ce531407..6c556df833e 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -5,6 +5,7 @@ import { AgentModeSchema } from "../../types/mode"; import { ChatUsageDisplaySchema } from "./chatStats"; import { StreamErrorTypeSchema } from "./errors"; import { + ContextWindowTokensSchema, FilePartSchema, ModelFallbackRecordSchema, MuxMessageSchema, @@ -175,6 +176,7 @@ export const StreamStartEventSchema = z.object({ "frontends must prefer it over re-resolving the raw model against a " + "possibly refreshed providers config", }), + contextWindowTokens: ContextWindowTokensSchema.optional(), routedThroughGateway: z.boolean().optional(), routeProvider: z.string().optional(), historySequence: z.number().meta({ @@ -198,6 +200,23 @@ export const StreamStartEventSchema = z.object({ .meta({ description: "ACP prompt correlation id for matching stream events" }), }); +// Fallback-only attempt boundary: reset request usage before switching model identity. +// Preserve accumulated parts, message lifecycle, and the separately recorded session ledger. +export const StreamMetadataEventSchema = z.object({ + type: z.literal("stream-metadata"), + workspaceId: z.string(), + messageId: z.string(), + metadata: z.object({ + model: z.string(), + metadataModel: z.string(), + contextWindowTokens: ContextWindowTokensSchema, + thinkingLevel: ThinkingLevelSchema.optional(), + routedThroughGateway: z.boolean(), + routeProvider: z.string().nullable(), + modelFallback: ModelFallbackRecordSchema.optional(), + }), +}); + export const StreamDeltaEventSchema = z.object({ type: z.literal("stream-delta"), workspaceId: z.string(), @@ -257,6 +276,7 @@ export const StreamEndEventSchema = z.object({ .object({ model: z.string(), metadataModel: z.string().optional(), + contextWindowTokens: ContextWindowTokensSchema.optional(), agentId: AgentIdSchema.optional().catch(undefined), mode: AgentModeSchema.optional().catch(undefined), thinkingLevel: ThinkingLevelSchema.optional(), @@ -689,6 +709,7 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [ DeleteMessageSchema, StreamLifecycleEventSchema, StreamStartEventSchema, + StreamMetadataEventSchema, StreamDeltaEventSchema, StreamEndEventSchema, StreamAbortEventSchema, diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index b86bf7aa469..254270d129d 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -8,6 +8,7 @@ import type { import type { StreamStartEvent, + StreamMetadataEvent, StreamDeltaEvent, StreamEndEvent, StreamAbortEvent, @@ -62,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) @@ -87,6 +89,10 @@ export function isStreamStart(msg: WorkspaceChatMessage): msg is StreamStartEven return (msg as { type?: string }).type === "stream-start"; } +export function isStreamMetadata(msg: WorkspaceChatMessage): msg is StreamMetadataEvent { + return msg.type === "stream-metadata"; +} + export function isStreamDelta(msg: WorkspaceChatMessage): msg is StreamDeltaEvent { return (msg as { type?: string }).type === "stream-delta"; } 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); + } +} diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 587be9673b5..68e7988b92f 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -953,6 +953,8 @@ export interface MuxMetadata { model?: string; /** Resolved pricing model for token/cost metadata lookups when the selected model uses Treat as mapping. */ metadataModel?: string; + /** Request-pinned context capacity so reconnect/settings refresh cannot resize an active request. */ + contextWindowTokens?: number | null; /** Effective thinking/reasoning level used for this response (after model policy clamping). */ thinkingLevel?: ThinkingLevel; /** @deprecated Legacy gateway flag; prefer routeProvider for source attribution. */ @@ -981,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/common/types/review.formatForModel.test.ts b/src/common/types/review.formatForModel.test.ts index 277b6b16756..0c904435a90 100644 --- a/src/common/types/review.formatForModel.test.ts +++ b/src/common/types/review.formatForModel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { + stripRenderedReviews, formatReviewForModel, isPlanFilePath, normalizePlanFilePath, @@ -14,6 +15,26 @@ const baseReviewData: ReviewNoteData = { userNote: "Please rename this variable.", }; +describe("stripRenderedReviews", () => { + const reviews = [baseReviewData]; + const prefix = reviews.map(formatReviewForModel).join("\n\n"); + test.each(["", "\n", "\n\n"])("removes one matching prefix with separator %j", (separator) => { + const body = ` ${prefix}\n\nliteral user text\n `; + expect(stripRenderedReviews(`${prefix}${separator}${body}`, reviews)).toBe(body); + }); + test("keeps nonmatching metadata and interior/literal blocks byte-for-byte", () => { + for (const body of [`before\n${prefix}`, ` ${prefix}`, "other content"]) { + expect(stripRenderedReviews(body, reviews)).toBe(body); + } + expect(stripRenderedReviews(prefix)).toBe(prefix); + expect(stripRenderedReviews(prefix, [])).toBe(prefix); + expect(stripRenderedReviews(prefix, [{ ...baseReviewData, userNote: "different" }])).toBe( + prefix + ); + expect(stripRenderedReviews(prefix, reviews)).toBe(""); + }); +}); + describe("formatReviewForModel", () => { test("formats standard code review notes with file path and line range", () => { expect(formatReviewForModel(baseReviewData)).toBe( diff --git a/src/common/types/review.ts b/src/common/types/review.ts index dc706329a2f..37b5229238c 100644 --- a/src/common/types/review.ts +++ b/src/common/types/review.ts @@ -368,3 +368,14 @@ export function formatReviewForModel(data: ReviewNoteData): string { return `\nRe ${location}\n\`\`\`\n${data.selectedCode}\n\`\`\`\n> ${data.userNote.trim()}\n`; } + +/** Remove one metadata-rendered prefix when restoring editable input; never scan body text. */ +export function stripRenderedReviews(content: string, reviews?: readonly ReviewNoteData[]): string { + if (!reviews || reviews.length === 0) return content; + const reviewText = reviews.map(formatReviewForModel).join("\n\n"); + if (!content.startsWith(reviewText)) return content; + const remainder = content.slice(reviewText.length); + if (remainder.startsWith("\n\n")) return remainder.slice(2); + if (remainder.startsWith("\n")) return remainder.slice(1); + return remainder; +} diff --git a/src/common/types/stream.ts b/src/common/types/stream.ts index 223e58df8fe..de2fbfaef30 100644 --- a/src/common/types/stream.ts +++ b/src/common/types/stream.ts @@ -19,6 +19,7 @@ import type { StreamDeltaEventSchema, StreamEndEventSchema, StreamStartEventSchema, + StreamMetadataEventSchema, ToolCallDeltaEventSchema, ToolCallEndEventSchema, ToolCallExecutionStartEventSchema, @@ -40,6 +41,27 @@ import type { export type CompletedMessagePart = MuxReasoningPart | MuxTextPart | MuxToolPart; export type StreamStartEvent = z.infer; +export type StreamMetadataEvent = z.infer; +/** + * A fallback starts a new attempt in the same message. Replace its owned fields and + * discard refused-attempt usage; that spend already belongs to the session ledger. + */ +export function copyStreamMetadataSnapshot(metadata: StreamMetadataEvent["metadata"]) { + return { + model: metadata.model, + metadataModel: metadata.metadataModel, + contextWindowTokens: metadata.contextWindowTokens, + thinkingLevel: metadata.thinkingLevel, + routedThroughGateway: metadata.routedThroughGateway, + routeProvider: metadata.routeProvider ?? undefined, + modelFallback: metadata.modelFallback, + usage: undefined, + contextUsage: undefined, + providerMetadata: undefined, + contextProviderMetadata: undefined, + }; +} + export type StreamDeltaEvent = z.infer; export type StreamEndEvent = z.infer; export type StreamAbortReason = z.infer; 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; +} 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); +} diff --git a/src/common/utils/tokens/tokenMeterUtils.test.ts b/src/common/utils/tokens/tokenMeterUtils.test.ts index 5531640f97c..b95bf061dfe 100644 --- a/src/common/utils/tokens/tokenMeterUtils.test.ts +++ b/src/common/utils/tokens/tokenMeterUtils.test.ts @@ -42,6 +42,23 @@ describe("calculateTokenMeterData", () => { }, }; + test("request-pinned capacity overrides live limits for every segment", () => { + const result = calculateTokenMeterData( + SAMPLE_USAGE, + "anthropic:claude-sonnet-4-20250514", + false, + false, + providerConfigWithOverride, + 1_000_000 + ); + expect(result.maxTokens).toBe(1_000_000); + expect(result.totalPercentage).toBeCloseTo(1.1); + expect(result.segments.find((segment) => segment.type === "input")?.percentage).toBe(1); + expect( + calculateTokenMeterData(SAMPLE_USAGE, "openai:gpt-4o", false, false, null, null).maxTokens + ).toBeUndefined(); + }); + test("uses custom context override for beta Sonnet models", () => { const result = calculateTokenMeterData( SAMPLE_USAGE, diff --git a/src/common/utils/tokens/tokenMeterUtils.ts b/src/common/utils/tokens/tokenMeterUtils.ts index 77b542d9e43..b14bf0d750d 100644 --- a/src/common/utils/tokens/tokenMeterUtils.ts +++ b/src/common/utils/tokens/tokenMeterUtils.ts @@ -60,11 +60,16 @@ export function calculateTokenMeterData( model: string, use1M: boolean, verticalProportions = false, - providersConfig: ProvidersConfigMap | null = null + providersConfig: ProvidersConfigMap | null = null, + contextLimitOverride?: number | null ): TokenMeterData { if (!usage) return { segments: [], totalTokens: 0, totalPercentage: 0 }; - const maxTokens = getEffectiveContextLimit(model, use1M, providersConfig) ?? undefined; + // Active requests retain their pinned limit, including an explicitly unknown capacity. + const maxTokens = + (contextLimitOverride !== undefined + ? contextLimitOverride + : getEffectiveContextLimit(model, use1M, providersConfig)) ?? undefined; // Total tokens used in the request. // For Anthropic prompt caching, cacheCreate tokens are reported separately but still 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(", ") }; +} 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 diff --git a/src/node/builtinSkills/xum-docs.md b/src/node/builtinSkills/xum-docs.md index e44e6b69c5a..5b39c29598b 100644 --- a/src/node/builtinSkills/xum-docs.md +++ b/src/node/builtinSkills/xum-docs.md @@ -102,6 +102,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/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 3db8393a96b..c9e80d13c46 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, @@ -100,6 +102,7 @@ import { subscribeBackgroundBashes, subscribeMemoryChanges, subscribeMetadata, + subscribeServerChanges, subscribeOpenSettings, subscribePolicyChanges, subscribeDesignExperiment, @@ -234,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) @@ -252,6 +259,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) @@ -1630,6 +1655,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/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/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/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index 0fdd316ca9c..3282e84ae12 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -6,6 +6,12 @@ import * as fsPromises from "node:fs/promises"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { getTotalCost } from "@/common/utils/tokens/usageAggregator"; +import { prepareUserMessageForSend } from "@/common/types/message"; +import { + QueuedMessageChangedEventSchema, + RestoreToInputEventSchema, +} from "@/common/orpc/schemas/stream"; +import type { WorkspaceChatMessage } from "@/common/orpc/types"; import type { MuxMessageMetadata } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import type { WorkspaceGoalService } from "./workspaceGoalService"; @@ -798,6 +804,163 @@ describe("AgentSession queued message tool-call dispatch", () => { } }); + test("queue enqueue, full replay and restoration preserve text while omitting malformed review arrays", async () => { + const workspaceId = "queue-restore-invalid-reviews"; + const { session, cleanup } = await createAgentSessionHarness({ workspaceId }); + const review = { + filePath: "file.ts", + lineRange: "1", + selectedCode: "code", + userNote: "Keep this text", + }; + const original = prepareUserMessageForSend({ + text: " User body\n", + reviews: [review], + }).finalText; + const invalidReviews: unknown[] = [ + null, + "legacy reviews", + [null], + ["legacy review"], + [{}], + [{ ...review, selectedCode: 42 }], + [{ ...review, filePath: 42 }], + [{ ...review, lineRange: null }], + [{ ...review, userNote: false }], + // These optional fields are not read by the formatter, so try/catch never detects them. + [{ ...review, oldStart: "1" }], + [{ ...review, newStart: null }], + [review, { ...review, selectedDiff: 42 }], + ]; + const restored: Array> = []; + const queued: Array> = []; + const observe = ({ message }: { message: WorkspaceChatMessage }) => { + if (message.type === "restore-to-input") restored.push(message); + if (message.type === "queued-message-changed") queued.push(message); + }; + const unsubscribe = session.onChatEvent(observe); + try { + for (const reviews of invalidReviews) { + session.queueMessage(original, { + model: TEST_MODEL, + agentId: "exec", + muxMetadata: { reviews }, + }); + const live = QueuedMessageChangedEventSchema.parse(queued.at(-1)); + expect(live).toMatchObject({ + displayText: original.trim(), + queuedMessages: [original.trim()], + }); + expect(live.reviews).toBeUndefined(); + const beforeReplay = queued.length; + await session.replayHistory(observe, { type: "full" }); + expect(queued).toHaveLength(beforeReplay + 1); + expect(QueuedMessageChangedEventSchema.parse(queued.at(-1))).toEqual(live); + session.restoreQueueToInput(); + expect(QueuedMessageChangedEventSchema.parse(queued.at(-1)).queuedMessages).toEqual([]); + const event = restored.at(-1); + expect(RestoreToInputEventSchema.safeParse(event).success).toBe(true); + expect(event?.text).toBe(original); + expect(event?.reviews).toBeUndefined(); + expect(session.hasQueuedMessages()).toBe(false); + } + expect(restored).toHaveLength(invalidReviews.length); + // A later valid enqueue still normalizes and restores its structured reviews. + session.queueMessage(original, { + model: TEST_MODEL, + agentId: "exec", + muxMetadata: { reviews: [review] }, + }); + expect(QueuedMessageChangedEventSchema.parse(queued.at(-1)).reviews).toEqual([review]); + await session.replayHistory(observe, { type: "full" }); + expect(QueuedMessageChangedEventSchema.parse(queued.at(-1)).reviews).toEqual([review]); + session.restoreQueueToInput(); + expect( + queued.every((event) => QueuedMessageChangedEventSchema.safeParse(event).success) + ).toBe(true); + expect(restored.every((event) => RestoreToInputEventSchema.safeParse(event).success)).toBe( + true + ); + const valid = RestoreToInputEventSchema.parse(restored.at(-1)); + expect(valid.text).toBe(" User body\n"); + expect(valid.reviews).toEqual([review]); + } finally { + unsubscribe(); + await session.dispose(); + await cleanup(); + } + }); + + test("restoreQueueToInput emits all reviewed bodies once without dispatching or changing cancellation", async () => { + const workspaceId = "queue-restore-reviewed-input"; + const streamMessage = mock(() => + Promise.resolve(Ok(createStartedTurnHandle(session.closingSignal))) + ); + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId, + aiServiceOverrides: { streamMessage }, + }); + const reviews = ["one.ts", "two.ts"].map((filePath) => ({ + filePath, + lineRange: "1", + selectedCode: "code", + userNote: filePath, + })); + const fileParts = [ + { url: "data:text/plain;base64,dGV4dA==", mediaType: "text/plain", filename: "notes.txt" }, + ]; + const restored: Array> = []; + const displays: string[] = []; + const canceled: string[] = []; + const unsubscribe = session.onChatEvent(({ message }) => { + if (message.type === "restore-to-input") restored.push(message); + if (message.type === "queued-message-changed") displays.push(message.displayText); + }); + try { + session.queueMessage( + "hidden", + { model: TEST_MODEL, agentId: "exec" }, + { + synthetic: true, + onCanceled: (reason) => { + canceled.push(reason); + }, + } + ); + const prepared = reviews.map((review, index) => + prepareUserMessageForSend({ text: `body ${index}`, reviews: [review] }) + ); + for (const message of prepared) + session.queueMessage(message.finalText, { + model: TEST_MODEL, + agentId: "exec", + muxMetadata: message.metadata, + fileParts, + }); + expect(displays.at(-1)).toBe(prepared.map((message) => message.finalText).join("\n")); + session.restoreQueueToInput(); + expect(restored).toEqual([ + { + type: "restore-to-input", + workspaceId, + text: "body 0\nbody 1", + fileParts: [...fileParts, ...fileParts], + reviews, + }, + ]); + expect(prepareUserMessageForSend(restored[0])).toEqual( + prepareUserMessageForSend({ text: "body 0\nbody 1", reviews }) + ); + expect(canceled).toHaveLength(1); + expect(session.hasQueuedMessages()).toBe(false); + expect(streamMessage).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + await session.dispose(); + await cleanup(); + } + }); + test("restoreQueueToInput discards a queued heartbeat instead of restoring it", async () => { const workspaceId = "queue-dispatch-restore-discards-heartbeat"; const { session, cleanup } = await createAgentSessionHarness({ workspaceId }); diff --git a/src/node/services/agentSession.streamMetadata.test.ts b/src/node/services/agentSession.streamMetadata.test.ts new file mode 100644 index 00000000000..c7e1d7ce6d0 --- /dev/null +++ b/src/node/services/agentSession.streamMetadata.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { WorkspaceChatMessageSchema } from "@/common/orpc/schemas/stream"; +import type { StreamMetadataEvent } from "@/common/types/stream"; +import { createAgentSessionHarness, createStreamLifecycleMocks } from "./agentSession.testHarness"; + +test("forwards fallback metadata only for the current workspace and stream without a lifecycle transition", async () => { + let activeMessageId: string | undefined = "current"; + const harness = await createAgentSessionHarness({ + workspaceId: "workspace", + captureEvents: true, + streamManager: { + ...createStreamLifecycleMocks(), + getStreamInfo: () => + activeMessageId + ? { + messageId: activeMessageId, + parts: [], + toolCompletionTimestamps: new Map(), + } + : undefined, + }, + }); + try { + const event: StreamMetadataEvent = { + type: "stream-metadata", + workspaceId: "workspace", + messageId: "current", + metadata: { + model: "local:unknown", + metadataModel: "local:unknown", + contextWindowTokens: null, + routedThroughGateway: false, + routeProvider: null, + }, + }; + harness.aiEmitter.emit(event.type, { ...event, workspaceId: "other" }); + harness.aiEmitter.emit(event.type, { ...event, messageId: "stale" }); + expect(harness.events).toHaveLength(0); + harness.aiEmitter.emit(event.type, event); + expect(harness.events.map((message) => WorkspaceChatMessageSchema.parse(message))).toEqual([ + event, + ]); + activeMessageId = undefined; + harness.aiEmitter.emit(event.type, event); + expect(harness.events).toHaveLength(1); + } finally { + await harness.session.dispose(); + await harness.cleanup(); + } +}); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index e00e7cde006..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. @@ -8188,6 +8223,13 @@ export class AgentSession { this.emitChatEvent(payload); } }); + forward("stream-metadata", (payload) => { + if ( + payload.type === "stream-metadata" && + this.streamManager.getStreamInfo(this.workspaceId)?.messageId === payload.messageId + ) + this.emitChatEvent(payload); + }); forward("stream-delta", (payload) => { this.markActiveStreamHadAnyOutput(); this.emitChatEvent(payload); @@ -9041,9 +9083,9 @@ export class AgentSession { } const queuedMessages = this.messageQueue.getVisibleMessages(); - const displayText = this.messageQueue.getVisibleDisplayText(); + const displayText = this.messageQueue.getVisibleRestoreText(); const fileParts = this.messageQueue.getVisibleFileParts(); - const reviews = this.messageQueue.getVisibleReviews(); + const reviews = this.messageQueue.getVisibleRestoreReviews(); const hasVisibleContent = queuedMessages.length > 0 || fileParts.length > 0 || (reviews?.length ?? 0) > 0; diff --git a/src/node/services/agentSession.userStopReplay.test.ts b/src/node/services/agentSession.userStopReplay.test.ts new file mode 100644 index 00000000000..4a064df09a7 --- /dev/null +++ b/src/node/services/agentSession.userStopReplay.test.ts @@ -0,0 +1,152 @@ +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(); + // 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(); + } + } 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(); + } + } +}); diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index f9aa7222bcb..a42b53802fd 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -5307,7 +5307,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",', @@ -7069,6 +7073,135 @@ 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. 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", + "", + "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.", + "", + "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.", + "", + "## 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` 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.", + "", + "With the Message composer focused, Enter sends in desktop-sized windows with a fine pointer; Shift+Enter inserts a newline. Narrow windows and coarse-pointer devices keep Enter for newlines. Ctrl+Enter (or Cmd+Enter on macOS) sends while idle on either layout. Escape uses the active conversation's Stop action. Shortcuts respect disabled actions and composition, do not run from other inputs or modal dialogs, and do not queue messages or stop a turn when Enter is pressed during streaming.", + "", + "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 runs under Node and forwards streamed subscription responses without buffering.", + "", + "## 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", + "```", + "", + "Send/Stop keyboard shortcuts currently apply only to React Native Web. Native software-keyboard Enter behavior is unchanged; native hardware-keyboard Send/Stop bindings are not implemented. The installed native TextInput APIs do not expose the modifier/source information needed for that behavior without additional native integration.", + "", + "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.", + "", + "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.", + "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 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?", + "", + "**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", @@ -8810,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", diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index fce92a88e54..51b83d2b3e0 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -2409,6 +2409,47 @@ describe("AIService.streamMessage compaction boundary slicing", () => { expect(usage.reasoning.cost_usd).toBeGreaterThan(0); }); + it("passes current attempt capacity to the stream engine instead of historical retry preferences", async () => { + using xumHome = new DisposableTempDir("ai-service-request-context-capacity"); + const projectPath = path.join(xumHome.path, "project"); + await fs.mkdir(projectPath, { recursive: true }); + const workspaceId = "workspace-context-capacity"; + const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); + const harness = createHarness(xumHome.path, metadata, { useRequestedModelString: true }); + const model = "anthropic:claude-sonnet-4-20250514"; + new ProvidersConfigStore(harness.config.rootDir).saveProvidersConfig({ + anthropic: { + apiKey: "sk-test", + models: [{ id: model.slice("anthropic:".length), contextWindowTokens: 200_000 }], + }, + }); + const messages = [ + createMuxMessage("latest-user", "user", "continue", { + retrySendOptions: { + model, + agentId: "exec", + thinkingLevel: "off", + providerOptions: { anthropic: { use1MContext: true } }, + }, + }), + ]; + for (const disableBetaFeatures of [true, false]) { + const result = await harness.service.streamMessage({ + messages, + workspaceId, + modelString: model, + thinkingLevel: "off", + muxProviderOptions: { anthropic: { use1MContext: true, disableBetaFeatures } }, + }); + expect(result.success).toBe(true); + } + expect(harness.startStreamCalls.map((call) => call.contextWindowTokens)).toEqual([ + 200_000, 1_000_000, + ]); + expect(harness.startStreamCalls[0].headers?.["anthropic-beta"]).toBeUndefined(); + expect(harness.startStreamCalls[1].headers?.["anthropic-beta"]).toBeDefined(); + }); + it("passes the resolved routeProvider into initial stream metadata", async () => { using xumHome = new DisposableTempDir("ai-service-route-provider-present"); const projectPath = path.join(xumHome.path, "project"); diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index a48141387ce..907d3b5bbdd 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, beforeEach } from "bun:test"; import { MessageQueue } from "./messageQueue"; -import { createMuxMessage, type MuxMessageMetadata } from "@/common/types/message"; +import { + createMuxMessage, + prepareUserMessageForSend, + type MuxMessageMetadata, +} from "@/common/types/message"; +import type { ReviewNoteData } from "@/common/types/review"; import type { SendMessageOptions } from "@/common/orpc/types"; describe("MessageQueue", () => { @@ -10,6 +15,144 @@ describe("MessageQueue", () => { queue = new MessageQueue(); }); + describe("restore projection", () => { + const reviews: ReviewNoteData[] = ["first.ts", "second.ts"].map((filePath) => ({ + filePath, + lineRange: "1", + selectedCode: "const value = 1;", + userNote: `Review ${filePath}`, + })); + const file = { + url: "data:text/plain;base64,dGV4dA==", + mediaType: "text/plain", + filename: "notes.txt", + }; + const options = { model: "test-model", agentId: "exec" }; + + it.each([false, true])( + "restores each enqueue's reviews while preserving dispatch (sealed=%s)", + (sealed) => { + const bodies = ["First body\nliteral user XML", "Second body"]; + const prepared = bodies.map((text, index) => + prepareUserMessageForSend({ text, reviews: [reviews[index]] }) + ); + prepared.forEach((message) => + queue.add( + message.finalText, + { ...options, muxMetadata: message.metadata, fileParts: [file] }, + { sealed } + ) + ); + expect(queue.entryCount()).toBe(sealed ? 2 : 1); + expect(queue.getVisibleDisplayText()).toBe( + prepared.map((message) => message.finalText).join("\n") + ); + expect(queue.getVisibleReviews()).toEqual(sealed ? reviews : [reviews[0]]); + expect(queue.getVisibleRestoreText()).toBe(bodies.join("\n")); + expect(queue.getVisibleRestoreReviews()).toEqual(reviews); + expect(queue.getVisibleFileParts()).toEqual([file, file]); + const resend = prepareUserMessageForSend({ + text: queue.getVisibleRestoreText(), + reviews: queue.getVisibleRestoreReviews(), + }); + expect(resend).toEqual(prepareUserMessageForSend({ text: bodies.join("\n"), reviews })); + const first = queue.dequeueNext(); + expect(first.message).toBe( + sealed ? prepared[0].finalText : prepared.map((message) => message.finalText).join("\n") + ); + expect(first.options?.muxMetadata).toEqual(prepared[0].metadata); + expect(queue.getVisibleRestoreReviews()).toEqual(sealed ? [reviews[1]] : undefined); + queue.clear(); + expect(queue.getVisibleRestoreText()).toBe(""); + expect(queue.getVisibleRestoreReviews()).toBeUndefined(); + } + ); + + it("strips one prepended prefix, not matching literal body text or later unreviewed adds", () => { + const prefix = prepareUserMessageForSend({ text: "", reviews }).finalText; + const body = `${prefix}\n\nLiteral repeated review, kept verbatim`; + const prepared = prepareUserMessageForSend({ text: body, reviews }); + queue.add(prepared.finalText, { ...options, muxMetadata: prepared.metadata }); + queue.add(body, options); + expect(queue.getVisibleRestoreText()).toBe(`${body}\n${body}`); + expect(queue.getVisibleRestoreReviews()).toEqual(reviews); + }); + + it("preserves raw text without the matching prefix and review-only/file-only payloads", () => { + const raw = "literal\n\nraw user input"; + queue.add(raw, { ...options, muxMetadata: { reviews: [reviews[0]] } }); + const prepared = prepareUserMessageForSend({ text: "", reviews: [reviews[1]] }); + queue.add(prepared.finalText, { ...options, muxMetadata: prepared.metadata }); + queue.add("", { ...options, fileParts: [file], muxMetadata: { reviews: [reviews[0]] } }); + expect(queue.getVisibleRestoreText()).toBe(raw); + expect(queue.getVisibleRestoreReviews()).toEqual([...reviews, reviews[0]]); + expect(queue.getVisibleFileParts()).toEqual([file]); + }); + + it("keeps restore records aligned through partial dedupe removals and preserves hidden cancellation", () => { + reviews.forEach((review, index) => { + const prepared = prepareUserMessageForSend({ text: `body ${index}`, reviews: [review] }); + queue.addOnce( + prepared.finalText, + { ...options, muxMetadata: prepared.metadata }, + `review-${index}` + ); + }); + queue.add("tail", options); + queue.removeByDedupeKeyPrefix("review-0"); + expect(queue.getVisibleRestoreText()).toBe("body 1\ntail"); + expect(queue.getVisibleRestoreReviews()).toEqual([reviews[1]]); + queue.removeByDedupeKeyPrefix("review-1"); + expect(queue.getVisibleRestoreText()).toBe("tail"); + expect(queue.getVisibleRestoreReviews()).toBeUndefined(); + const onCanceled = () => undefined; + const hidden = prepareUserMessageForSend({ text: "hidden", reviews }); + queue.add( + hidden.finalText, + { ...options, muxMetadata: hidden.metadata }, + { synthetic: true, onCanceled } + ); + expect(queue.getVisibleRestoreText()).toBe("tail"); + expect(queue.getVisibleRestoreReviews()).toBeUndefined(); + expect(queue.getClearCallbacks()).toContainEqual({ onCanceled }); + }); + + it("does not throw away input when unvalidated metadata contains malformed reviews", () => { + expect(queue.add("raw input", { ...options, muxMetadata: { reviews: [{}] } })).toBe(true); + expect(queue.getVisibleRestoreText()).toBe("raw input"); + expect(queue.getVisibleRestoreReviews()).toBeUndefined(); + }); + + it("validates display reviews per first-entry metadata without altering dispatch or later restore notes", () => { + const invalidMetadata = { reviews: [reviews[0], { ...reviews[1], selectedDiff: 42 }] }; + queue.add("invalid first", { ...options, muxMetadata: invalidMetadata, fileParts: [file] }); + queue.add("valid batched", { ...options, muxMetadata: { reviews: [reviews[1]] } }); + expect(queue.getVisibleReviews()).toBeUndefined(); + expect(queue.getVisibleRestoreReviews()).toEqual([reviews[1]]); + queue.add( + "valid separate", + { ...options, muxMetadata: { reviews: [reviews[0]] } }, + { sealed: true } + ); + expect(queue.getVisibleReviews()).toEqual([reviews[0]]); + expect(queue.getVisibleDisplayText()).toBe("invalid first\nvalid batched\nvalid separate"); + const dispatched = queue.dequeueNext(); + expect(dispatched.options?.muxMetadata).toBe(invalidMetadata); + expect(dispatched.options?.fileParts).toEqual([file]); + }); + + it("keeps raw commands for compaction and agent-skill restoration", () => { + for (const metadata of [ + { type: "compaction-request", rawCommand: "/compact" }, + { type: "agent-skill", rawCommand: "/skill", skillName: "skill", scope: "built-in" }, + ]) { + queue.add("expanded prompt", { ...options, muxMetadata: metadata }); + expect(queue.getVisibleRestoreText()).toBe(metadata.rawCommand); + queue.clear(); + } + }); + }); + describe("authoredAtMs", () => { it("returns the request-entry authoring time from dequeueNext when provided", () => { // Codex P2 (PRRT_kwDOPxxmWM6b-orA): the sender captures authoring time diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index eeecddfab8c..72119cc7921 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -1,11 +1,12 @@ import type { GoalSyntheticMessageKind } from "@/constants/goals"; import assert from "@/common/utils/assert"; +import { ReviewNoteDataSchema } from "@/common/orpc/schemas/stream"; import type { FilePart, SendMessageOptions } from "@/common/orpc/types"; import { AGENT_PEER_MESSAGE_DEDUPE_PREFIX } from "@/constants/agentMessaging"; import { getValidAgentPeerTriggerMeta } from "@/common/utils/agentMessageEnvelope"; import type { SendMessageError } from "@/common/types/errors"; import type { MuxMessage } from "@/common/types/message"; -import type { ReviewNoteData } from "@/common/types/review"; +import { stripRenderedReviews, type ReviewNoteData } from "@/common/types/review"; // Type guard for compaction request metadata (for display text) interface CompactionMetadata { @@ -79,15 +80,14 @@ function isAgentPeerMessageMetadata(meta: unknown): boolean { return obj.type === "agent-peer-message" && typeof obj.fromWorkspaceId === "string"; } -// Type guard for metadata with reviews -interface MetadataWithReviews { - reviews?: ReviewNoteData[]; -} +const ReviewsSchema = ReviewNoteDataSchema.array(); -function hasReviews(meta: unknown): meta is MetadataWithReviews { - if (typeof meta !== "object" || meta === null) return false; - const obj = meta as Record; - return Array.isArray(obj.reviews); +// Both live/replayed queue snapshots and restored input cross a strict event schema. +// Reject whole malformed arrays rather than partially stripping their rendered prefixes. +function getValidatedReviews(meta: unknown): ReviewNoteData[] | undefined { + if (typeof meta !== "object" || meta === null || !("reviews" in meta)) return undefined; + const parsed = ReviewsSchema.safeParse(meta.reviews); + return parsed.success ? parsed.data : undefined; } type GoalInterventionPolicy = NonNullable; @@ -181,6 +181,9 @@ interface QueueEntry { goalKind?: GoalSyntheticMessageKind; goalId?: string; messages: string[]; + // Restore each original send before batching loses its review metadata/boundaries. + // These records never affect provider dispatch or the queued-message display. + restoreMessages: Array<{ text: string; reviews?: ReviewNoteData[]; messageIndex: number | null }>; /** First muxMetadata added to this entry (never overwritten by later batched adds). */ muxMetadata?: unknown; latestOptions?: SendMessageOptions; @@ -616,6 +619,7 @@ export class MessageQueue { } } else { entry = { + restoreMessages: [], messages: [], fileParts: [], dedupeKeys: new Set(), @@ -648,6 +652,13 @@ export class MessageQueue { ? "pause" : (options?.goalInterventionPolicy ?? entry.goalInterventionPolicy); + const reviews = getValidatedReviews(options?.muxMetadata); + entry.restoreMessages.push({ + text: stripRenderedReviews(message, reviews), + reviews, + messageIndex: trimmedMessage.length > 0 ? entry.messages.length : null, + }); + // Add text message if non-empty if (trimmedMessage.length > 0) { entry.messages.push(trimmedMessage); @@ -764,7 +775,7 @@ export class MessageQueue { return entries.flatMap((entry) => entry.messages); } - private getDisplayTextForEntries(entries: readonly QueueEntry[]): string { + private getDisplayTextForEntries(entries: readonly QueueEntry[], restore = false): string { return entries .map((entry) => { if ( @@ -773,7 +784,11 @@ export class MessageQueue { ) { return entry.muxMetadata.rawCommand; } - return entry.messages.join("\n"); + return ( + restore + ? entry.restoreMessages.map((message) => message.text).filter(Boolean) + : entry.messages + ).join("\n"); }) .filter((text) => text.length > 0) .join("\n"); @@ -784,9 +799,7 @@ export class MessageQueue { } private getReviewsForEntries(entries: readonly QueueEntry[]): ReviewNoteData[] | undefined { - const reviews = entries.flatMap((entry) => - hasReviews(entry.muxMetadata) ? (entry.muxMetadata.reviews ?? []) : [] - ); + const reviews = entries.flatMap((entry) => getValidatedReviews(entry.muxMetadata) ?? []); return reviews.length > 0 ? reviews : undefined; } @@ -814,6 +827,19 @@ export class MessageQueue { return this.getDisplayTextForEntries(this.getVisibleEntries()); } + /** Editable text, normalized before per-send review boundaries were flattened. */ + getVisibleRestoreText(): string { + return this.getDisplayTextForEntries(this.getVisibleEntries(), true); + } + + /** Preserve later batched sends' reviews without changing first-metadata dispatch semantics. */ + getVisibleRestoreReviews(): ReviewNoteData[] | undefined { + const reviews = this.getVisibleEntries().flatMap((entry) => + entry.restoreMessages.flatMap((message) => message.reviews ?? []) + ); + return reviews.length > 0 ? reviews : undefined; + } + /** Get accumulated file parts across all entries. */ getFileParts(): FilePart[] { return this.getFilePartsForEntries(this.entries); @@ -896,12 +922,20 @@ export class MessageQueue { // but multiple progress sends can still batch together. Remove only the matched messages and // preserve unrelated keys/messages that share the same entry. const matchingKeySet = new Set(matchingKeys); + const keptIndexes = new Map(); const keptMessages = entry.messages.filter((_message, index) => { const key = [...entry.dedupeKeys][index]; - return key == null || !matchingKeySet.has(key); + const keep = key == null || !matchingKeySet.has(key); + if (keep) keptIndexes.set(index, keptIndexes.size); + return keep; }); if (keptMessages.length > 0) { entry.messages = keptMessages; + entry.restoreMessages = entry.restoreMessages.flatMap((message) => { + if (message.messageIndex === null) return [message]; + const messageIndex = keptIndexes.get(message.messageIndex); + return messageIndex === undefined ? [] : [{ ...message, messageIndex }]; + }); for (const key of matchingKeys) { entry.dedupeKeys.delete(key); } 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, diff --git a/src/node/services/streamManager.continuousCompaction.test.ts b/src/node/services/streamManager.continuousCompaction.test.ts index 691c246e086..0bc6340199b 100644 --- a/src/node/services/streamManager.continuousCompaction.test.ts +++ b/src/node/services/streamManager.continuousCompaction.test.ts @@ -772,8 +772,8 @@ describe("continuous prefix prepareStep and journal", () => { sliced || mode === "journal-failure" || mode === "ambiguous-anchor") - ? ["prefix-swap-invalidated"] - : [] + ? ["stream-metadata", "prefix-swap-invalidated"] + : ["stream-metadata"] ); if ( consumed && diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 097938ba782..f194f7a43ef 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -8,6 +8,7 @@ import type { ProvidersConfigMap } from "@/common/orpc/types"; import { StreamAbortEventSchema, StreamEndEventSchema, + StreamMetadataEventSchema, ToolCallStartEventSchema, } from "@/common/orpc/schemas/stream"; import type { @@ -4562,6 +4563,133 @@ describe("StreamManager - empty stream completions", () => { expect(swappedRequest.system).toBe("fallback system"); }); + test("fallback metadata is published before each hop's usage without restarting the message", async () => { + const manager = new StreamManager(historyService); + const events: TurnEngineEvent[] = []; + manager.setEventSink((event) => { + events.push(event); + }); + const workspaceId = "fallback-live-metadata"; + const messageId = "fallback-message"; + await appendPartialAssistantForTests(workspaceId, messageId, 1); + const models = [KNOWN_MODELS.SONNET.id, "openai:gpt-4o", "local:unknown"]; + const gates = [Promise.withResolvers(), Promise.withResolvers()]; + const waiting = [Promise.withResolvers(), Promise.withResolvers()]; + let hop = 0; + Reflect.set(manager, "createStreamResult", () => { + const index = hop++; + return createStreamResultForTests( + (async function* () { + yield { type: "text-delta", text: `fallback ${index}` }; + yield { + type: "finish-step", + usage: { inputTokens: 100_000, outputTokens: 0, totalTokens: 100_000 }, + }; + waiting[index].resolve(); + await gates[index].promise; + yield { + type: "finish", + finishReason: index === 0 ? "content-filter" : "stop", + ...(index === 0 ? { rawFinishReason: "refusal" } : {}), + }; + })() + ); + }); + const info = createStreamInfoForTests({ + messageId, + model: models[0], + metadataModel: models[0], + contextWindowTokens: 1_000_000, + historySequence: 1, + runtime: LOCAL_TEST_RUNTIME, + initialMetadata: { + routeProvider: "coder", + routedThroughGateway: true, + contextBudgetLimit: 50_000, + }, + streamResult: createStreamResultForTests( + (async function* () { + await Promise.resolve(); + yield { type: "text-delta", text: "original" }; + yield { type: "tool-call", toolCallId: "tool", toolName: "bash", input: {} }; + yield { + type: "tool-result", + toolCallId: "tool", + toolName: "bash", + output: { success: true }, + }; + yield { type: "finish", finishReason: "content-filter", rawFinishReason: "refusal" }; + })() + ), + modelFallback: { + requestedModel: models[0], + refusedModels: [], + original: {}, + options: { + chain: models.slice(1), + prepare: (modelString: string) => + Promise.resolve( + Ok({ + model: createTestLanguageModel(), + modelString, + messages: [], + system: "", + tools: {}, + contextWindowTokens: modelString === models[1] ? 400_000 : null, + initialMetadataPatch: { routeProvider: undefined, routedThroughGateway: false }, + }) + ), + }, + }, + }); + getWorkspaceStreamsForTests(manager).set(workspaceId, info); + const processing = getProcessStreamWithCleanupForTests(manager).call( + manager, + workspaceId, + info, + 1 + ); + try { + for (let index = 0; index < gates.length; index++) { + await waiting[index].promise; + const updates = events.filter((event) => event.type === "stream-metadata"); + expect(updates).toHaveLength(index + 1); + expect(StreamMetadataEventSchema.parse(updates[index]).metadata).toMatchObject({ + model: models[index + 1], + metadataModel: models[index + 1], + contextWindowTokens: index === 0 ? 400_000 : null, + routeProvider: null, + modelFallback: { requestedModel: models[0], refusedModels: models.slice(0, index + 1) }, + }); + expect( + events.filter((event) => event.type === "stream-start" && !event.replay) + ).toHaveLength(1); + expect(events.some((event) => event.type === "stream-end")).toBe(false); + const nextUsage = events.findIndex( + (event, offset) => offset > events.indexOf(updates[index]) && event.type === "usage-delta" + ); + expect(nextUsage).toBeGreaterThan(events.indexOf(updates[index])); + await manager.replayStream(workspaceId); + const replay = events.findLast((event) => event.type === "stream-start"); + expect(replay).toMatchObject({ + replay: true, + model: models[index + 1], + contextWindowTokens: index === 0 ? 400_000 : null, + }); + const parts = info.parts as Array<{ type: string; text?: string; toolCallId?: string }>; + expect(parts.some((part) => part.text === "original")).toBe(true); + expect(parts.some((part) => part.toolCallId === "tool")).toBe(true); + expect((info.initialMetadata as { contextBudgetLimit: number }).contextBudgetLimit).toBe( + 50_000 + ); + gates[index].resolve(); + } + } finally { + for (const gate of gates) gate.resolve(); + await processing; + } + }); + test("partial refusal with a configured fallback continues from cloned partial output", async () => { const streamManager = new StreamManager(historyService); const errorEvents: unknown[] = []; @@ -6271,6 +6399,77 @@ describe("StreamManager - previousResponseId recovery", () => { } }); +describe("StreamManager - request-pinned context capacity", () => { + test("live starts, replay, and partials keep capacity across config refreshes and new streams", async () => { + let liveProviders: ProvidersConfigMap = {}; + const manager = new StreamManager(historyService, undefined, () => liveProviders); + const starts: Array> = []; + onTurnEngineEvent(manager, "stream-start", (event) => starts.push(event)); + const workspaceId = "pinned-context"; + for (const [index, capacity] of [1_000_000, 150_000, null].entries()) { + const messageId = `capacity-${index}`; + await appendPartialAssistantForTests(workspaceId, messageId, index); + let release!: () => void; + const finish = new Promise((resolve) => { + release = resolve; + }); + let markDeltaProcessed!: () => void; + const deltaProcessed = new Promise((resolve) => { + markDeltaProcessed = resolve; + }); + Reflect.set(manager, "createStreamResult", () => + createStreamResultForTests( + (async function* () { + yield { type: "text-delta", text: "hello" }; + markDeltaProcessed(); + await finish; + yield { type: "finish", finishReason: "stop" }; + })() + ) + ); + const result = await manager.startStream( + testStartOptions({ + workspaceId, + messageId, + model: createTestLanguageModel(), + historySequence: index, + contextWindowTokens: capacity, + }) + ); + expect(result.success).toBe(true); + if (!result.success) throw new Error("Expected stream start"); + try { + await deltaProcessed; + liveProviders = { + openai: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: [{ id: "gpt-4.1-mini", contextWindowTokens: 99_000 }], + }, + }; + await manager.replayStream(workspaceId); + expect(starts.slice(-2).map((event) => event.contextWindowTokens)).toEqual([ + capacity, + capacity, + ]); + expect(starts.at(-1)?.replay).toBe(true); + const info = getWorkspaceStreamsForTests(manager).get(workspaceId); + await getPrivateMethodForTests<(workspaceId: string, info: unknown) => Promise>( + manager, + "flushPartialWrite" + ).call(manager, workspaceId, info); + expect((await historyService.readPartial(workspaceId))?.metadata?.contextWindowTokens).toBe( + capacity + ); + } finally { + release(); + await result.data.completion; + } + } + }); +}); + describe("StreamManager - replayStream", () => { function createReplayStreamManager(): StreamManager { const streamManager = new StreamManager(historyService); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index edc09d6ee46..3ec74a87b9b 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -35,6 +35,7 @@ import { Ok, Err } from "@/common/types/result"; import { log, type Logger } from "./log"; import type { StreamStartEvent, + StreamMetadataEvent, StreamDeltaEvent, StreamEndEvent, StreamAbortEvent, @@ -198,6 +199,7 @@ type StreamToken = string & { __brand: "StreamToken" }; export type TurnEngineEvent = | StreamStartEvent + | StreamMetadataEvent | StreamDeltaEvent | StreamEndEvent | StreamAbortEvent @@ -295,6 +297,8 @@ interface StreamRequestOptions { } export interface TurnExecutionOptions extends StreamRequestOptions { + /** Effective capacity computed with the provider request; null is authoritative unknown. */ + contextWindowTokens?: number | null; workspaceId: string; historySequence: number; runtime: Runtime; @@ -379,6 +383,7 @@ interface StreamRequestConfig { * verbatim would leak provider-specific options/messages across providers). */ interface PreparedModelFallback { + contextWindowTokens?: number | null; contextBudgetMemoryWritable?: boolean; contextBudgetLimit?: number; model: LanguageModel; @@ -697,6 +702,7 @@ interface WorkspaceStreamInfo { model: string; /** Metadata model resolved from provider mapping for cost/token metadata lookups. */ metadataModel: string; + contextWindowTokens: number | null; /** Effective thinking level after model policy clamping */ thinkingLevel?: string; initialMetadata?: Partial; @@ -2822,6 +2828,7 @@ export class StreamManager { pendingToolExecutionStarts: new Map(), model: modelString, metadataModel, + contextWindowTokens: options.contextWindowTokens ?? null, thinkingLevel, initialMetadata, toolModelUsages: [], @@ -3169,6 +3176,7 @@ export class StreamManager { // diverge from the backend ledger when a Coder catalog refresh // removes/retags the instance mid-stream. metadataModel: streamInfo.metadataModel, + contextWindowTokens: streamInfo.contextWindowTokens, routedThroughGateway, ...(routeProvider != null && { routeProvider }), historySequence, @@ -3400,6 +3408,7 @@ export class StreamManager { ...streamInfo.initialMetadata, model: canonicalModel, metadataModel: streamInfo.metadataModel, + contextWindowTokens: streamInfo.contextWindowTokens, routedThroughGateway, ...(streamInfo.thinkingLevel && { thinkingLevel: streamInfo.thinkingLevel as ThinkingLevel, @@ -3701,6 +3710,7 @@ export class StreamManager { streamInfo.reasoningBackfillStartIndex = preserveParts ? streamInfo.parts.length : undefined; streamInfo.model = prepared.data.modelString; + streamInfo.contextWindowTokens = prepared.data.contextWindowTokens ?? null; streamInfo.metadataModel = this.resolveMetadataModel( prepared.data.modelString, prepared.data.providersConfig @@ -3713,6 +3723,11 @@ export class StreamManager { streamInfo.initialMetadata = { ...streamInfo.initialMetadata, ...prepared.data.initialMetadataPatch, + // A direct/custom fallback must clear the previous gateway's attribution. + routeProvider: prepared.data.initialMetadataPatch?.routeProvider, + routedThroughGateway: + prepared.data.initialMetadataPatch?.routedThroughGateway ?? + prepared.data.modelString.startsWith("mux-gateway:"), modelFallback: { requestedModel: fallbackState.requestedModel, refusedModels: [...fallbackState.refusedModels], @@ -3724,6 +3739,22 @@ export class StreamManager { runLanguageModelCleanup(streamInfo.request.model); streamInfo.request = nextRequest; streamInfo.streamResult = nextStreamResult; + if (!streamInfo.abortController.signal.aborted) { + this.emitTurnEvent({ + type: "stream-metadata", + workspaceId, + messageId: streamInfo.messageId, + metadata: { + model: metadataModelIdentity(streamInfo.model), + metadataModel: streamInfo.metadataModel, + contextWindowTokens: streamInfo.contextWindowTokens, + thinkingLevel: streamInfo.thinkingLevel as ThinkingLevel | undefined, + routedThroughGateway: streamInfo.initialMetadata.routedThroughGateway ?? false, + routeProvider: streamInfo.initialMetadata.routeProvider ?? null, + modelFallback: streamInfo.initialMetadata.modelFallback, + }, + }); + } await this.tokenTracker.setModel(streamInfo.model, streamInfo.metadataModel); if ( consumedSwap && @@ -4432,6 +4463,7 @@ export class StreamManager { ...streamInfo.initialMetadata, // TurnRequestBuilder-provided metadata (systemMessageTokens, etc) model: canonicalModel, metadataModel: streamInfo.metadataModel, + contextWindowTokens: streamInfo.contextWindowTokens, routedThroughGateway, ...(streamInfo.thinkingLevel && { thinkingLevel: streamInfo.thinkingLevel as ThinkingLevel, diff --git a/src/node/services/turnRequestBuilder.test.ts b/src/node/services/turnRequestBuilder.test.ts index 1d647988f5e..d2e8fb6a11f 100644 --- a/src/node/services/turnRequestBuilder.test.ts +++ b/src/node/services/turnRequestBuilder.test.ts @@ -404,6 +404,67 @@ describe("TurnRequestBuilder tool scope", () => { }); describe("TurnRequestBuilder model attempt preparation", () => { + it("pins context capacity from the actual request and refreshes it for a new attempt", async () => { + const harness = await createPreparationHarness(); + try { + const model = "anthropic:claude-sonnet-4-20250514"; + const providers: ProvidersConfigMap = { + anthropic: { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: [{ id: model.slice("anthropic:".length), contextWindowTokens: 200_000 }], + }, + }; + const options = preparationOptions(providers, { + rawModelString: model, + canonicalModelString: model, + effectiveModelString: model, + optionsModelString: model, + muxProviderOptions: { anthropic: { use1MContextModels: [model] } }, + }); + const first = harness.builder.prepareModelAttempt(options); + expect(first.contextWindowTokens).toBe(1_000_000); + expect(first.requestHeaders?.["anthropic-beta"]).toBeDefined(); + // A later resume/new attempt owns new options; earlier user retry metadata is irrelevant. + options.muxProviderOptions.anthropic!.disableBetaFeatures = true; + providers.anthropic.models = [ + { id: model.slice("anthropic:".length), contextWindowTokens: 150_000 }, + ]; + const resumed = harness.builder.prepareModelAttempt(options); + expect(resumed.requestHeaders?.["anthropic-beta"]).toBeUndefined(); + expect(resumed.contextWindowTokens).toBe(150_000); + expect(first.contextWindowTokens).toBe(1_000_000); + options.muxProviderOptions.anthropic!.disableBetaFeatures = false; + providers.openrouter = { + isConfigured: true, + isEnabled: true, + apiKeySet: true, + models: [ + { id: `anthropic/${model.slice("anthropic:".length)}`, contextWindowTokens: 175_000 }, + ], + }; + const transformed = harness.builder.prepareModelAttempt({ + ...options, + effectiveModelString: `openrouter:anthropic/${model.slice("anthropic:".length)}`, + routeProvider: "openrouter", + }); + // Transforming gateways cannot carry the beta header, even with saved 1M intent. + expect(transformed.requestHeaders?.["anthropic-beta"]).toBeUndefined(); + expect(transformed.contextWindowTokens).toBe(175_000); + const unknown = harness.builder.prepareModelAttempt({ + ...options, + rawModelString: "local:unknown", + effectiveModelString: "local:unknown", + canonicalModelString: "local:unknown", + optionsModelString: "local:unknown", + }); + expect(unknown.contextWindowTokens).toBeNull(); + } finally { + await harness.cleanup(); + } + }); + it("merges call settings and provider extras at the resolved namespace", async () => { const harness = await createPreparationHarness(); try { diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index c6bdeee8271..b12ab90a4da 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -100,6 +100,7 @@ import type { WorkspaceMCPOverrides } from "@/common/types/mcp"; import { isExecLikeEditingCapableInResolvedChain } from "@/common/utils/agentTools"; import { resolveModelParameterOverrides } from "@/common/utils/ai/modelParameterOverrides"; import { + ANTHROPIC_1M_CONTEXT_HEADER, buildProviderOptions, buildRequestHeaders, resolveProviderOptionsNamespaceKey, @@ -612,6 +613,7 @@ export interface PrepareModelAttemptOptions { } interface PreparedModelAttempt { + contextWindowTokens: number | null; providerOptions: Record; requestHeaders: Record | undefined; resolvedOverrides: ReturnType; @@ -769,6 +771,15 @@ export class TurnRequestBuilder { }; options.recordStartupPhaseTiming?.("buildRequestConfigMs", buildRequestConfigStartedAt); return { + // Pin against the actual routed model and the exact beta decision sent on this request, + // not live preferences or the preceding user's retry options (which can differ on resume). + contextWindowTokens: getEffectiveContextLimit( + options.effectiveModelString, + requestHeaders?.["anthropic-beta"]?.split(",").includes(ANTHROPIC_1M_CONTEXT_HEADER) === + true, + options.providersConfigSnapshot, + { openaiWireFormat: options.muxProviderOptions.openai?.wireFormat } + ), providerOptions: mergeExtras(providerOptions), requestHeaders, resolvedOverrides, @@ -2585,6 +2596,7 @@ export class TurnRequestBuilder { engineTools: attemptPayload.tools ?? attemptTools, toolNamesForSentinel, forcedFirstStepToolNames, + contextWindowTokens: preparedAttempt.contextWindowTokens, providerOptions: preparedAttempt.providerOptions, headers: preparedAttempt.requestHeaders, resolvedOverrides: preparedAttempt.resolvedOverrides, @@ -2938,6 +2950,7 @@ export class TurnRequestBuilder { return Ok({ onStreamConstructed: nextRequest.onStreamConstructed, + contextWindowTokens: nextRequest.contextWindowTokens, rebuildFirstStepForThinkingLevel: nextRequest.rebuildFirstStepForThinkingLevel, model: nextRequest.model, modelString: nextModelString, @@ -3030,6 +3043,7 @@ export class TurnRequestBuilder { tools: toolsForStream, contextBudgetMemoryWritable: primaryRequest.contextBudgetMemoryWritable, contextBudgetLimit: primaryRequest.contextBudgetLimit, + contextWindowTokens: primaryRequest.contextWindowTokens, initialMetadata: { ...(requestHistorySequence >= 0 ? { requestHistorySequence } : {}), systemMessageTokens, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index e49d9bf1c7e..119a4b112ce 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -12787,6 +12787,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 b21fdd796f2..9f5944acd9f 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -293,6 +293,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"; @@ -9256,6 +9257,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