Decouple bundled UI from runtime API and add remote instance tooling (#1228)

Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 00:43:05 +03:00
committed by GitHub
parent a4314c189b
commit 2031e3b4a8
282 changed files with 16524 additions and 4259 deletions
+306
View File
@@ -0,0 +1,306 @@
---
name: ui-api-decoupling
description: Use when creating or modifying OpenChamber UI data access, RuntimeAPIs, runtimeFetch/runtime-url auth, authenticated browser assets, OpenCode SDK calls, VS Code bridges, Electron runtime switching, or web server API endpoints.
license: MIT
compatibility: opencode
---
## Overview
OpenChamber shared UI runs against web, Electron desktop, remote server URLs, and VS Code webviews. API code must preserve that runtime boundary.
**Core principle:** official OpenCode API calls go through `@opencode-ai/sdk/v2` via `opencodeClient`; OpenChamber-owned capabilities go through `RuntimeAPIs` or explicit OpenChamber routes; runtime transport preserves SDK-generated requests exactly.
## Scope
Use this skill for changes touching UI data loading, session/message operations, provider/auth/config calls, filesystem/git/terminal/settings APIs, runtime switching, desktop/VS Code bridges, or server routes under `/api/*`.
Do not use this skill for pure visual-only UI work unless the change adds, removes, or reshapes data access.
## First Step
Before editing, classify every endpoint or capability involved:
| Need | Correct path |
|------|--------------|
| Official OpenCode endpoint | `opencodeClient` or `opencodeClient.getSdkClient()` |
| SDK gap to official OpenCode | Central helper in `opencodeClient` using `runtimeFetch`, documented as SDK gap |
| OpenChamber-owned feature route | `RuntimeAPIs` first, otherwise `runtimeFetch` to explicit OC route |
| Native/runtime capability | Extend `RuntimeAPIs`, implement per runtime, consume via hook/registry |
| Browser/realtime URL that cannot send headers (iframe, download/open link, SSE, WebSocket, preview subresource) | `getRuntimeUrlResolver()` helpers plus `oc_url_token` allowlist, not hardcoded URLs |
| UI-controlled authenticated asset fetch (small icons/thumbnails where JS can fetch) | `runtimeFetch` with `Authorization`, then `URL.createObjectURL(blob)` |
## Mandatory Rules
1. **Never bypass the SDK for official OpenCode APIs**
- Do not add raw `fetch` or direct `runtimeFetch` from feature UI to official endpoints such as `/api/session`, `/api/permission`, `/api/question`, `/api/auth`, `/api/provider`, `/api/command`, `/api/app`.
- Use `opencodeClient` wrappers or `opencodeClient.getSdkClient()`.
- If the SDK lacks a method, add a narrow wrapper in `packages/ui/src/lib/opencode/client.ts`, mark it as an SDK gap, and add transport coverage when body/method/query/signal matters.
2. **Preserve SDK request fidelity**
- Runtime transport must preserve `Request` method, body, headers, query string, auth, and abort signal.
- Do not rebuild a request from only `url` and `init`.
- Regression tests belong near `packages/ui/src/lib/runtime-fetch.test.ts`, `packages/vscode/webview/api/bridge.test.ts`, and proxy tests when transport changes.
3. **Use `RuntimeAPIs` for runtime-owned capabilities**
- Files, git, terminal, settings, notifications, GitHub helpers, client auth, editor/VS Code actions, and tools belong in `RuntimeAPIs` when shared UI needs runtime-specific behavior.
- React components use `useRuntimeAPIs()` or `useRuntimeAPI()`.
- Non-React modules use `getRegisteredRuntimeAPIs()` only when a hook cannot be used.
- Direct `window.__OPENCHAMBER_RUNTIME_APIS__` reads are entrypoint/legacy escape hatches, not a new feature pattern.
4. **Keep OpenChamber routes explicit**
- Direct `runtimeFetch` is acceptable for OpenChamber-only routes such as `/api/config/settings`, `/api/config/skills`, `/api/config/commands`, `/api/fs`, `/api/git`, `/api/terminal`, `/api/preview`, `/api/magic-prompts`, `/api/tts`, and `/api/openchamber/tunnel`.
- Register OpenChamber routes before the generic OpenCode proxy, or the proxy will steal the path.
- Shared UI depending on an OC route requires web and VS Code parity, or an explicit deterministic unsupported response.
5. **Do not hardcode local runtime URLs**
- Do not infer `localhost`, server ports, or `/api` origins in shared UI.
- Use `getRuntimeUrlResolver()` at call time.
- Do not use the exported `runtimeUrl` singleton for new code because it can capture stale resolver state.
6. **Treat runtime auth as transport state**
- HTTP auth is owned by `runtime-auth` and `runtimeFetch`; callers pass route paths and let transport attach `Authorization` only for the active runtime service URL.
- Browser/realtime transports that cannot set headers use `runtime-url` helpers and short-lived `oc_url_token` query auth.
- Never put long-lived client bearer tokens in URLs. `oc_client_token` should appear only in legacy stripping/rejection paths, tests, or migration compatibility code.
- Do not manually append `oc_url_token`; use resolver helpers and add server-side allowlist coverage when a new browser-consumed route needs URL auth.
7. **Runtime switch must reset stale state**
- Runtime base URL, runtime key, bearer token, SDK clients, terminal transports, session memory, and UI runtime-scoped state must not be cached blindly.
- Use `switchRuntimeEndpoint`, `subscribeRuntimeEndpointChanged`, `opencodeClient.reconnectToRuntimeBaseUrl()`, and runtime-keyed store state.
8. **Authoritative fetches must signal failure**
- If a caller uses returned data to replace, delete, or clear authoritative state, the method must throw or return `null` on failure.
- Do not swallow errors and return `[]`, `{}`, or `null` when that value is also a valid empty success unless the caller treats it as display-only.
9. **Privileged runtime switching requires explicit user intent**
- Electron connect/deep-link flows that import a remote host, store a client token, change default host, or switch active runtime must show an in-app confirmation before writing config or switching.
- The confirmation may show the label and server URL, but never the token.
- Existing-host imports still require confirmation because they can overwrite the stored token or change the active runtime.
## HTTP Request Decision Rules
For normal HTTP requests to the active OpenChamber runtime, use `runtimeFetch` with the route path. Let `runtimeFetch` resolve the current runtime base URL and auth at call time.
```ts
// Good: runtimeFetch owns base URL, runtime auth, and runtime switching.
await runtimeFetch('/health');
await runtimeFetch('/auth/session', { method: 'GET' });
await runtimeFetch('/api/config/settings');
await runtimeFetch('/api/fs/raw', { query: { path: absolutePath } });
// Bad: callers should not prebuild runtime HTTP URLs for fetches.
await fetch(getRuntimeUrlResolver().health());
await runtimeFetch(getRuntimeUrlResolver().api('/api/config/settings'));
await runtimeFetch(getRuntimeUrlResolver().rawFile(absolutePath));
```
Use `runtimeFetch(..., { query })` instead of manually appending query strings when the request targets `/api`, `/auth`, or `/health`.
```ts
// Good
await runtimeFetch('/api/git/status', { query: { directory, mode: 'light' } });
// Avoid
await runtimeFetch(`/api/git/status?directory=${encodeURIComponent(directory)}&mode=light`);
```
Use `getRuntimeUrlResolver()` only when the resulting URL is consumed by the browser or a realtime transport, not immediately fetched as HTTP:
```ts
// Good resolver usage: URL is assigned to browser/realtime consumers.
const rawImageSrc = getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { path });
const iframeSrc = getRuntimeUrlResolver().authenticatedAsset(proxyPath);
const eventUrl = getRuntimeUrlResolver().sse('/api/event');
const socketUrl = getRuntimeUrlResolver().websocket('/api/terminal/ws');
```
Plain `fetch` is acceptable only for intentional external network requests that do not target the OpenChamber runtime, such as npm registry, models.dev, or a user-provided `https://...` URL.
## Authenticated Browser Assets
Authenticated assets need an explicit transport choice. Pick based on who owns the request:
| Asset/request shape | Correct pattern |
|---------------------|-----------------|
| React/UI code can fetch it and the object is small (project icons, small thumbnails, generated previews) | `runtimeFetch('/api/...')` with `Authorization`, read `blob()`, render a `URL.createObjectURL(blob)` |
| Browser must own the URL (iframe `src`, image/download/open-link for large raw files, rewritten preview subresources) | `getRuntimeUrlResolver().authenticatedAsset(...)` so the URL carries short-lived `oc_url_token` |
| Realtime transports | `getRuntimeUrlResolver().sse(...)` or `.websocket(...)`; never generic fetch/proxy paths |
For object-URL assets:
- Key caches by runtime identity (`getRuntimeApiBaseUrl()` or runtime key), entity ID, version/update timestamp, and render-affecting options.
- Cap caches and revoke evicted object URLs with `URL.revokeObjectURL`.
- Render a deterministic fallback while loading or after failure; do not leave empty chrome.
- Keep the fetch display-only unless the caller intentionally treats failure as authoritative.
For URL-auth assets:
- The server route must explicitly allow `oc_url_token` in `packages/web/server/lib/ui-auth/ui-auth.js` and have coverage in `ui-auth.test.js`.
- Scope allowlists narrowly to browser-readable GET routes or specific realtime upgrade paths. Do not allow arbitrary `/api/*`.
- Use short-lived `oc_url_token` only. Do not revive `oc_client_token` in query strings.
Preview iframe/subresource rules:
- Use preview proxy helpers so `oc_preview_token` and `oc_url_token` propagate to rewritten resources and redirects.
- Strip legacy `oc_client_token` before forwarding to dev servers.
- Do not use `postMessage('*')`; target the known preview origin.
- Preserve CSP where possible. If injecting a bridge, prefer a per-response nonce and remove only directives that block framing or the bridge.
## Runtime API Extension Pattern
When adding a native/per-runtime capability:
1. Add or extend the interface in `packages/ui/src/lib/api/types.ts`.
2. Implement web HTTP behavior in `packages/web/src/api/*` and compose it in `packages/web/src/api/index.ts`.
3. Implement VS Code webview API in `packages/vscode/webview/api/*` and compose it in `packages/vscode/webview/api/index.ts`.
4. Add extension-host handlers in `packages/vscode/src/bridge-*-runtime.ts` when filesystem, git, settings, or OpenCode manager access is required.
5. Keep Electron shared through the web runtime unless it needs shell-only IPC in `packages/electron/main.mjs` or `packages/electron/preload.mjs`.
6. Register the runtime APIs through app entrypoints and consume through `RuntimeAPIProvider`.
## VS Code Route Parity
For any shared UI call to `/api/*`, decide the VS Code behavior explicitly:
| Route type | VS Code handling |
|------------|------------------|
| OpenChamber local route | Handle in `packages/vscode/webview/main.tsx` and bridge to extension host when needed |
| Official OpenCode route | Let generic fetch proxy forward to OpenCode via `api:proxy` |
| SSE route | Use `api:sse:start` / stream messages / `api:sse:stop`, never generic proxy |
| Session message POST | Use `api:session:message` special proxy path |
| Unsupported native feature | Return stable 501/unsupported JSON, not silent fallback |
## Electron Security Boundary
Electron exposes API base and shell identity broadly, but privileged local capabilities stay local-only.
- `__OPENCHAMBER_API_BASE_URL__` and `__OPENCHAMBER_LOCAL_ORIGIN__` route requests.
- `__OPENCHAMBER_CLIENT_TOKEN__`, `__OPENCHAMBER_HOME__`, and `__TAURI__`-style IPC are local-page gated.
- Do not expose filesystem, shell, or host secrets to remote pages for UI convenience.
- Do not trust arbitrary loopback, `file://`, or `about:blank` origins as local UI. Gate privileged preload/IPC/token access to the packaged UI origin and exact runtime origins.
- Deep-links that add or switch remote runtimes are trust-boundary changes. Confirm before storing tokens or switching hosts.
## Common Anti-Patterns
| Anti-pattern | Use instead |
|--------------|-------------|
| `fetch('/api/session/...')` in shared UI | SDK through `opencodeClient` |
| `runtimeFetch('/api/session/...')` from a component | SDK wrapper or documented SDK-gap helper |
| `fetch(getRuntimeUrlResolver().health())` | `runtimeFetch('/health')` |
| `runtimeFetch(getRuntimeUrlResolver().api('/api/foo'))` | `runtimeFetch('/api/foo')` |
| `runtimeFetch(getRuntimeUrlResolver().rawFile(path))` | `runtimeFetch('/api/fs/raw', { query: { path } })` |
| New `/api/foo` only in web server | Web + VS Code route decision |
| Component reads `window.__OPENCHAMBER_RUNTIME_APIS__` | `useRuntimeAPIs()` / `useRuntimeAPI()` |
| Rebuilding `new Request(newUrl)` only | `new Request(newUrl, oldRequest)` plus merged headers |
| Returning `[]` on authoritative SDK failure | Throw or return `null` and preserve state |
| Caching `getRuntimeUrlResolver()` output forever | Read resolver/client at call time or reset on runtime switch |
| Manually appending `oc_client_token` or `oc_url_token` | `runtimeFetch` for HTTP, resolver helpers for browser/realtime URLs |
| Direct `<img src>` to a small authenticated app asset | `runtimeFetch` + `blob()` + object URL with fallback and bounded cache |
| Adding URL-auth access to a route without server allowlist tests | Narrow `oc_url_token` allowlist in `ui-auth.js` plus `ui-auth.test.js` coverage |
| Connect deep-link writes host config before consent | Confirm first, then import/switch |
## Verification Checklist
Before finalizing a UI/API decoupling change:
1. Official OpenCode routes use SDK wrappers or documented SDK-gap helpers.
2. OpenChamber routes are registered before the generic proxy.
3. VS Code has parity, proxy fallback, or explicit unsupported behavior.
4. Runtime transport preserves body, method, headers, query, auth, and abort signal.
5. Runtime auth/token handling uses `runtime-auth` and `runtime-url`.
6. No long-lived client bearer token is placed in a URL; browser/realtime URL auth uses scoped short-lived `oc_url_token` only.
7. Browser-consumed routes that need `oc_url_token` have narrow server allowlist and tests.
8. Runtime switch clears or scopes affected client/store/object-URL state.
9. Authoritative loaders distinguish failure from empty success.
10. Targeted tests cover changed transport, bridge, proxy, auth allowlist, or runtime API behavior.
## Implementation Map
### Shared UI Sources Of Truth
`packages/ui/src/lib/opencode/client.ts` is the central OpenCode SDK wrapper. It creates `@opencode-ai/sdk/v2` clients with `fetch: runtimeFetch`, runtime auth headers, current-directory handling, scoped clients, and convenience wrappers. Add official OpenCode API behavior here unless a feature directly consumes `getSdkClient()` in sync/runtime code.
`packages/ui/src/lib/runtime-fetch.ts` rewrites `/api`, `/auth`, and `/health` through the active runtime URL resolver and injects runtime auth. Its key contract is preserving SDK-created `Request` objects, including method, body, headers, query, and signal. For ordinary HTTP calls, pass route paths directly to `runtimeFetch`; do not pre-resolve them with `getRuntimeUrlResolver()` first.
`packages/ui/src/lib/runtime-url.ts` owns HTTP, auth, health, raw-file, SSE, WebSocket, and authenticated browser URL construction. `getRuntimeUrlResolver()` is the call-time source for browser-consumed URLs like iframe `src`, large/raw image `src`, download/open links, SSE URLs, and WebSocket URLs. `runtimeUrl` is not safe for new code that must survive runtime switches.
`packages/ui/src/lib/runtime-auth.ts` owns bearer-token state and short-lived URL-token minting. `runtimeFetch` merges `Authorization` unless a caller already supplied one. Runtime URL helpers add scoped `oc_url_token` where headers are impossible; they must never expose long-lived client bearer tokens in URLs.
### Runtime API Contract
`packages/ui/src/lib/api/types.ts` defines `RuntimeAPIs` and all per-runtime capability contracts.
`packages/ui/src/contexts/RuntimeAPIProvider.tsx` provides APIs to React and wraps `files` with a content cache that invalidates on write, delete, and rename.
`packages/ui/src/hooks/useRuntimeAPIs.ts` is the React consumption path. `packages/ui/src/contexts/runtimeAPIRegistry.ts` is the non-React escape hatch for modules that cannot use hooks.
`packages/ui/src/App.tsx` and app variants register APIs and reset runtime-scoped stores on `openchamber:runtime-endpoint-changed`.
### Web Runtime
`packages/web/src/runtimeConfig.ts` reads injected globals, configures the runtime URL resolver, sets the runtime bearer token, installs the runtime fetch bridge, and creates web APIs.
`packages/web/src/main.tsx`, `mobile-main.tsx`, and `mini-chat-main.tsx` assign `window.__OPENCHAMBER_RUNTIME_APIS__` before rendering shared UI.
`packages/web/src/api/index.ts` composes web `RuntimeAPIs` from implementations such as `files.ts`, `git.ts`, `terminal.ts`, `settings.ts`, `permissions.ts`, `github.ts`, `clientAuth.ts`, `push.ts`, and `tools.ts`.
Web runtime API implementations are normally HTTP clients for OpenChamber-owned server routes. Use `runtimeFetch` for HTTP requests; use `getRuntimeUrlResolver()` only when producing browser/realtime URLs that will not be immediately fetched by code.
### Server Routes And Proxy
`packages/web/server/index.js` starts the OpenChamber web server. Electron imports this server in-process.
`packages/web/server/lib/opencode/core-routes.js` installs JSON parsing for OpenChamber-owned `/api/*` route families.
`packages/web/server/lib/opencode/feature-routes-runtime.js` registers OpenChamber feature routes before the generic OpenCode proxy: filesystem, git, GitHub, quota, config entities, skills/plugins, magic prompts, session folders, scheduled tasks, and related features.
`packages/web/server/lib/opencode/proxy.js` is the generic `/api/*` proxy to upstream OpenCode. It strips the `/api` prefix, injects OpenCode auth headers, replays parsed bodies for non-GET requests, handles `/api/event` and `/api/global/event` as SSE, applies readiness gating, and canonicalizes directory query parameters.
OpenChamber-owned routes must be explicit and registered before the proxy. If a route is shared UI contract, add VS Code parity or a deterministic unsupported response.
If an OpenChamber route is consumed directly by the browser with `oc_url_token`, update the readable/realtime allowlist in `packages/web/server/lib/ui-auth/ui-auth.js` and add tests in `ui-auth.test.js`. Do not use URL tokens as a blanket `/api/*` auth bypass.
### VS Code Runtime
`packages/vscode/webview/api/index.ts` composes VS Code `RuntimeAPIs`. Terminal is a stub; files, git, settings, permissions, notifications, GitHub, tools, editor, and VS Code actions use the bridge.
`packages/vscode/webview/main.tsx` installs `window.__OPENCHAMBER_RUNTIME_APIS__` and overrides `window.fetch`. It handles OpenChamber local routes, then proxies generic OpenCode `/api/*` calls to the extension host. It has special branches for SSE and session message POST.
`packages/vscode/webview/requestBodyTransport.ts` extracts request bodies from SDK-style `Request` objects and `init.body` without losing bytes.
`packages/vscode/webview/api/bridge.ts` sends bridge messages, supports abort propagation, exposes `proxyApiRequest`, `proxySessionMessageRequest`, and SSE start/stop helpers.
`packages/vscode/src/bridge-proxy-runtime.ts` forwards generic OpenCode proxy requests to the live OpenCode API URL, merges sanitized headers with OpenCode auth, forwards body bytes, and rejects SSE through the generic proxy.
`packages/vscode/src/bridge-config-runtime.ts`, `bridge-fs-runtime.ts`, `bridge-git-runtime.ts`, and related bridge modules implement OpenChamber-owned route behavior in the extension host.
### Electron Runtime
`packages/electron/main.mjs` starts the web server in-process, resolves local/remote runtime target, tracks `apiBaseUrl` and `clientToken`, injects init scripts, confirms remote connect deep-links before storing tokens, and handles host switching.
`packages/electron/preload.mjs` exposes runtime globals. API base and local origin are broadly available for routing. Client token, home directory, and `__TAURI__` IPC stay local-page gated so remote pages cannot access local host capabilities.
Shared UI should not branch on Electron for backend behavior. Prefer web runtime APIs and the preload-provided `__TAURI__` compatibility shim only for shell capabilities that already exist in the shared runtime contract.
### Runtime Switch Flow
`packages/ui/src/lib/runtime-switch.ts` updates `__OPENCHAMBER_API_BASE_URL__`, `__OPENCHAMBER_CLIENT_TOKEN__`, runtime URL resolver, bearer token, and dispatches `openchamber:runtime-endpoint-changed`.
`packages/ui/src/App.tsx` reacts by preparing/restoring runtime-keyed session and UI state, reconnecting `opencodeClient`, clearing provider/agent connection state, disposing terminal transports, resetting streaming state, and triggering re-bootstrap.
Any cache keyed only by session ID, directory, or URL should be reviewed when runtime switching is involved. Use runtime keys when local and remote instances can share IDs or paths.
### Tests To Prefer
Use targeted transport/auth tests when changing request forwarding or URL auth: `packages/ui/src/lib/runtime-fetch.test.ts`, `packages/ui/src/lib/runtime-url.test.ts`, `packages/ui/src/lib/runtime-auth.test.ts`, `packages/web/server/lib/ui-auth/ui-auth.test.js`, `packages/vscode/webview/api/bridge.test.ts`, `packages/vscode/src/bridge-proxy-runtime.test.js`, `packages/web/server/opencode-proxy.test.js`, and `packages/web/server/lib/preview/proxy-runtime.test.js`.
Use runtime API tests near the implementation when adding or changing per-runtime behavior, for example web API tests under `packages/web/src/api/*.test.ts`, VS Code bridge tests under `packages/vscode/src/*test.js`, and UI wrapper tests under `packages/ui/src/lib/*test.ts`.
Run `bun run type-check` and `bun run lint` before finalizing code changes unless the user explicitly narrows validation.
## References
- SDK wrapper: `packages/ui/src/lib/opencode/client.ts`
- Runtime fetch/auth/url: `packages/ui/src/lib/runtime-fetch.ts`, `runtime-auth.ts`, `runtime-url.ts`
- Runtime API contract: `packages/ui/src/lib/api/types.ts`
- Web API composition: `packages/web/src/api/index.ts`, `packages/web/src/runtimeConfig.ts`
- VS Code bridge/proxy: `packages/vscode/webview/main.tsx`, `packages/vscode/webview/api/bridge.ts`, `packages/vscode/src/bridge-proxy-runtime.ts`
- Server proxy: `packages/web/server/lib/opencode/proxy.js`, `packages/web/server/lib/opencode/core-routes.js`
- UI auth and URL-token allowlists: `packages/web/server/lib/ui-auth/ui-auth.js`
- Preview proxy and rewritten browser subresources: `packages/web/server/lib/preview/proxy-runtime.js`
+89 -32
View File
@@ -1,15 +1,15 @@
# OpenChamber - AI Agent Reference (verified)
# OpenChamber - AI Agent Reference
## Core purpose
OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an OpenCode server (local auto-start or remote URL). UI uses HTTP + SSE via `@opencode-ai/sdk`.
OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an OpenCode server (local auto-start or remote URL). Official OpenCode traffic goes through `@opencode-ai/sdk`; OpenChamber-owned runtime capabilities go through `RuntimeAPIs`, `runtimeFetch`, and browser/realtime URL helpers.
## Runtime architecture (IMPORTANT)
- `Desktop` (Electron) boots the web server **in the same Node process** as the Electron main, then loads the web UI from `http://127.0.0.1:<port>`. No sidecar subprocess.
- `Desktop` (Tauri, legacy) still spawns `openchamber-server` as a bun-compiled sidecar binary. Kept only for auto-update compatibility with existing Tauri installs.
- All backend logic lives in `packages/web/server/*` (and `packages/vscode/*` for the VS Code runtime). The native shell is not a feature backend.
- The shell is used only for stable native integrations: menu, dialog (open folder), notifications, updater, deep-links, quit confirmation.
- Backend/domain logic lives in `packages/web/server/*` (and `packages/vscode/*` for VS Code bridge/runtime parity). Electron owns the desktop shell/security boundary: windows, menus, dialogs, notifications, updater, deep-links, runtime host switching, local IPC gates, and SSH/tunnel management.
- Do not add OpenCode feature backends to the native shell. Shared UI features should remain server/runtime APIs unless the capability is inherently native.
### Desktop shell: Electron is the target, Tauri is legacy
@@ -17,15 +17,15 @@ OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an O
- `packages/desktop/` (Tauri) is kept running in parallel only to preserve auto-update for existing installs until the cutover. Do **not** add features to it; do **not** port bug fixes back unless they actually affect currently-released Tauri users.
- Desktop-side changes (IPC handlers, native integrations, window/quit/notification behavior) land in `packages/electron/main.mjs` + `packages/electron/preload.mjs`. The `__TAURI__` shim exposed by the preload keeps the shared UI working against both shells, so renderer-side code should not branch on shell type.
- Electron imports the server via `@openchamber/web/server/index.js` (workspace dep) and calls `startWebUiServer({...})`. The returned handle has `getPort()` / `stop()`. Notifications flow via an `onDesktopNotification` callback injected at startup — no stdout-parsing IPC.
- Build/release: both shells ship in the same GitHub release today (`.github/workflows/release.yml`). The one-shot Tauri → Electron auto-update migration is documented in `docs/TAURI_TO_ELECTRON_CUTOVER.md`; run that when the user decides to flip.
- Build/release: Electron is the release target. The release workflow also repackages the signed Electron app as a Tauri updater payload for the one-shot migration path documented in `docs/TAURI_TO_ELECTRON_CUTOVER.md`.
- After the cutover ships and stabilises, `packages/desktop/` is deleted; this note collapses back to "Desktop is Electron".
## Tech stack (source of truth: `package.json`, resolved: `bun.lock`)
- Runtime/tooling: Bun (`package.json` `packageManager`), Node >=20 (`package.json` `engines`)
- UI: React, TypeScript, Vite, Tailwind v4
- State: Zustand (`packages/ui/src/stores/`)
- UI primitives: Base UI (`@base-ui/react`, primary source for dropdown/select/dialog/menu/tooltip/etc. — wrappers live in `packages/ui/src/components/ui/`), Radix UI (`package.json` deps, legacy usages being migrated), HeroUI (`package.json` deps), Remixicon (`package.json` deps)
- State: Zustand stores and sync layer (`packages/ui/src/stores/`, `packages/ui/src/sync/`)
- UI primitives: Base UI (`@base-ui/react`, primary source for dropdown/select/dialog/menu/tooltip/etc. — wrappers live in `packages/ui/src/components/ui/`), Radix UI (`package.json` deps, legacy usages being migrated), HeroUI (`package.json` deps), Remixicon as SVG sprite source only (use shared `Icon`, never direct `@remixicon/react` imports)
- Server: Express (`packages/web/server/index.js`)
- Desktop (forward): Electron 41 (`packages/electron/`)
- Desktop (legacy, maintenance-only): Tauri v2 (`packages/desktop/src-tauri/`)
@@ -53,6 +53,18 @@ Web runtime and server implementation for OpenChamber.
Server-side integration modules used by API routes and runtime services.
##### event-stream
OpenChamber-owned event stream helpers for server-sent runtime events.
- Module docs: `packages/web/server/lib/event-stream/DOCUMENTATION.md`
##### fs
Filesystem routes, raw file access, search helpers, and workspace-scoped file operations.
- Module docs: `packages/web/server/lib/fs/DOCUMENTATION.md`
##### quota
Quota provider registry, dispatch, and provider integrations for usage endpoints.
@@ -83,6 +95,18 @@ Notification message preparation utilities for system notifications, including t
- Module docs: `packages/web/server/lib/notifications/DOCUMENTATION.md`
##### scheduled-tasks
Scheduled task persistence, execution, and event fanout for recurring sessions.
- Module docs: `packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md`
##### text
Text processing helpers shared by server-side routes and summarization flows.
- Module docs: `packages/web/server/lib/text/DOCUMENTATION.md`
##### terminal
WebSocket protocol utilities for terminal input handling including message normalization, control frame parsing, and rate limiting.
@@ -95,12 +119,52 @@ Server-side text-to-speech services and summarization helpers for `/api/tts/*` e
- Module docs: `packages/web/server/lib/tts/DOCUMENTATION.md`
##### tunnels
Tunnel provider setup and runtime helpers for exposing OpenChamber over remote URLs.
- Module docs: `packages/web/server/lib/tunnels/DOCUMENTATION.md`
##### ui-auth
UI session auth, client tokens, URL-token scoping, passkey/reset flows, and route-level auth gates.
- Module docs: `packages/web/server/lib/ui-auth/DOCUMENTATION.md`
##### skills-catalog
Skills catalog management including discovery, installation, and configuration of agent skill packages.
- Module docs: `packages/web/server/lib/skills-catalog/DOCUMENTATION.md`
### ui
Shared React UI, sync layer, runtime API contracts, and stores.
#### sync
Session synchronization, event pipeline, optimistic updates, caches, and live-state stores.
- Module docs: `packages/ui/src/sync/DOCUMENTATION.md`
#### stores
Zustand store ownership, persistence expectations, and store-splitting guidance.
- Module docs: `packages/ui/src/stores/DOCUMENTATION.md`
#### session sidebar
Session sidebar grouping, ordering, virtualization-adjacent behavior, and project/worktree display.
- Module docs: `packages/ui/src/components/session/sidebar/DOCUMENTATION.md`
#### message parts
Chat message part rendering and message-row performance expectations.
- Module docs: `packages/ui/src/components/chat/message/parts/DOCUMENTATION.md`
## Build / dev commands (verified)
All scripts are in `package.json`.
@@ -126,9 +190,9 @@ All scripts are in `package.json`.
## OpenCode integration
- UI client wrapper: `packages/ui/src/lib/opencode/client.ts` (imports `@opencode-ai/sdk/v2`)
- SSE hookup: `packages/ui/src/hooks/useEventStream.ts`
- Sync/event pipeline: app roots mount `SyncProvider` from `packages/ui/src/sync/sync-context.tsx`; OpenCode SSE/WS event handling lives in `packages/ui/src/sync/event-pipeline.ts`
- Web server embeds/starts OpenCode server: `packages/web/server/index.js` (`createOpencodeServer`)
- Web runtime filesystem endpoints: search `packages/web/server/index.js` for `/api/fs/`
- Web runtime filesystem endpoints: `packages/web/server/lib/fs/routes.js`, registered by `packages/web/server/lib/opencode/feature-routes-runtime.js`
- External server support: Set `OPENCODE_HOST` (full base URL, e.g. `http://hostname:4096`) or `OPENCODE_PORT`, plus `OPENCODE_SKIP_START=true`, to connect to existing OpenCode instance
## Key UI patterns (reference files)
@@ -142,8 +206,10 @@ All scripts are in `package.json`.
## External / system integrations (active)
- Git: `packages/ui/src/lib/gitApi.ts`, `packages/web/server/index.js` (`simple-git`)
- Terminal PTY: `packages/web/server/index.js` (`bun-pty`/`node-pty`)
- Runtime API contracts: `packages/ui/src/lib/api/types.ts`; React consumption via `packages/ui/src/hooks/useRuntimeAPIs.ts`
- Runtime transport/auth: `packages/ui/src/lib/runtime-fetch.ts`, `packages/ui/src/lib/runtime-url.ts`, `packages/ui/src/lib/runtime-auth.ts`
- Git: `packages/ui/src/lib/gitApi.ts`, `packages/web/server/lib/git/service.js` (`simple-git`)
- Terminal PTY: `packages/web/server/lib/terminal/runtime.js` (`bun-pty`/`node-pty`)
- Skills catalog: `packages/web/server/lib/skills-catalog/`, UI: `packages/ui/src/components/sections/skills/`
## Agent constraints
@@ -268,29 +334,20 @@ Do not rely on prompts to enforce policy.
Detailed Clack UX patterns (primitives, prompt gating, and implementation checklist)
are defined in the `clack-cli-patterns` skill and should not be duplicated here.
## Clack CLI Skill (MANDATORY for terminal CLI work)
## Project Skills (MANDATORY)
When working on terminal CLI commands, prompts, or output formatting, agents **MUST** study the Clack CLI skill first.
Project skills live under `.agents/skills/*/SKILL.md`. Before editing, agents **MUST** load every skill whose trigger matches the work; if multiple rows apply, load all of them.
**Before starting terminal CLI work:**
| Work being done | Required skill call |
|---|---|
| Terminal CLI commands, prompts, or output formatting, especially `packages/web/bin/*` | `skill({ name: "clack-cli-patterns" })` |
| Shared UI data access, `RuntimeAPIs`, `runtimeFetch`, `runtime-url`, OpenCode SDK calls, VS Code bridges/proxies, authenticated browser assets, Electron runtime switching, or web server API endpoints | `skill({ name: "ui-api-decoupling" })` |
| UI components, styling, visual elements, colors, buttons, or icons | `skill({ name: "theme-system" })` |
| User-facing UI text: labels, buttons, placeholders, aria labels, empty/error/loading states, toasts, dialogs, settings copy, or navigation labels | `skill({ name: "locale-ui-patterns" })` |
| Settings pages, settings dialogs, configuration UI, or visual/layout changes inside Settings | `skill({ name: "settings-ui-patterns" })` |
| Drag-to-reorder, sortable lists/chips/grids, or `@dnd-kit` behavior including touch/mobile and wrapping variable-width items | `skill({ name: "drag-to-reorder" })` |
```
skill({ name: "clack-cli-patterns" })
```
Scope: terminal CLI only (for example `packages/web/bin/*`). Do not apply this requirement to VS Code or web UI work.
## Theme System (MANDATORY for UI work)
When working on any UI components, styling, or visual changes, agents **MUST** study the theme system skill first.
**Before starting any UI work:**
```
skill({ name: "theme-system" })
```
This skill contains all color tokens, semantic logic, decision tree, and usage patterns. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes.
Skill docs are the source of truth for detailed patterns. Do not duplicate their full guidance here; load the skill and follow it before making matching changes.
## Performance rules (MANDATORY)
@@ -408,4 +465,4 @@ A single store with N properties means every subscriber re-evaluates on every st
## Recent changes
- Releases + high-level changes: `CHANGELOG.md`
- Recent commits: `git log --oneline` (latest tags: `v1.4.6`, `v1.4.5`)
- Recent commits: `git log --oneline` (latest tags: `v1.11.7`, `v1.11.6`)
+28 -1
View File
@@ -107,6 +107,7 @@ openchamber --ui-password be-creative-here
```bash
openchamber --port 8080 # Custom port
openchamber --lan --port 3000 # Listen on LAN (0.0.0.0)
openchamber --ui-password secret # Password-protect UI
openchamber startup enable # Start at login as a native service
OPENCHAMBER_UI_PASSWORD=secret openchamber startup enable # Save service password env
@@ -120,6 +121,9 @@ openchamber tunnel start --provider cloudflare --mode quick --qr
openchamber tunnel start --provider cloudflare --mode managed-local --config ~/.cloudflared/config.yml
openchamber tunnel status --all # Show tunnel state across instances
openchamber tunnel stop --port 3000 # Stop tunnel only (server stays running)
openchamber connect-url --port 3000 # Add this server to OpenChamber Desktop
openchamber connect-url --server http://host:3000 --qr
openchamber connect-url --port 3000 --qr
openchamber logs # Follow latest instance logs
OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server
OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber # Connect via custom host/HTTPS
@@ -140,6 +144,29 @@ Bind managed OpenCode server to all interfaces (use only on trusted networks):
OPENCHAMBER_OPENCODE_HOSTNAME=0.0.0.0 openchamber --port 3000
```
Expose OpenChamber itself on your LAN:
```bash
openchamber --lan --port 3000 --ui-password secret
```
Add this server to OpenChamber Desktop or another OpenChamber app:
```bash
openchamber connect-url --port 3000 --qr
```
If no OpenChamber server is running on that port, `connect-url` starts one before generating the link.
Headless/API-only setup for a remote machine:
```bash
openchamber connect-url --port 3000 --api-only --lan --server http://your-host-or-ip:3000 --qr --ui-password secret
```
This runs OpenChamber as an API-only server without the desktop app or browser UI assets on that machine, then creates a link for Desktop to import. `--lan` makes the server reachable from other machines. `--server` is the address Desktop should use.
When OpenChamber was started with `--lan` or `--host 0.0.0.0`, `connect-url` automatically uses a detected LAN IP instead of `127.0.0.1`. Use `--server http://host:3000` to override the advertised address, and include `--lan` when `connect-url` needs to start the server for LAN access.
Paste the printed `openchamber://connect?...` link in Desktop under Settings -> Remote Instances -> Direct Instances -> Import Link. The link contains the server URL and a client token. It does not enable browser UI password protection; use `--ui-password` when exposing a server beyond localhost.
</details>
<details>
@@ -150,7 +177,7 @@ dev machine over a VPN (e.g. Tailscale) or LAN without a Cloudflare tunnel.
**How it works:**
- OpenCode runs as its own service, binding only to `localhost`.
- OpenChamber connects to it via `OPENCODE_HOST` and `--host 0.0.0.0` makes it reachable on your VPN IP.
- OpenChamber connects to it via `OPENCODE_HOST` and `--lan` makes it reachable on your VPN IP.
- `--foreground` keeps the CLI process alive so systemd can track and restart it.
**`~/.config/systemd/user/opencode.service`**
+29 -19
View File
@@ -14,18 +14,34 @@ const __dirname = path.dirname(__filename);
function fixHttpProxyDeprecation() {
try {
// Find the http-proxy package in node_modules
const httpProxyDir = path.join(__dirname, 'node_modules', 'http-proxy', 'lib', 'http-proxy');
const indexPath = path.join(httpProxyDir, 'index.js');
const commonPath = path.join(httpProxyDir, 'common.js');
if (!fs.existsSync(indexPath) || !fs.existsSync(commonPath)) {
return;
const candidateDirs = [
path.join(__dirname, 'node_modules', 'http-proxy', 'lib', 'http-proxy'),
];
const bunStoreDir = path.join(__dirname, 'node_modules', '.bun');
if (fs.existsSync(bunStoreDir)) {
for (const entry of fs.readdirSync(bunStoreDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('http-proxy@')) continue;
candidateDirs.push(path.join(bunStoreDir, entry.name, 'node_modules', 'http-proxy', 'lib', 'http-proxy'));
}
}
// Patch index.js
let needsPatch = false;
for (const httpProxyDir of candidateDirs) {
patchHttpProxyDir(httpProxyDir);
}
} catch {
// Silently handle errors - functionality is not affected
}
}
function patchHttpProxyDir(httpProxyDir) {
const indexPath = path.join(httpProxyDir, 'index.js');
const commonPath = path.join(httpProxyDir, 'common.js');
if (!fs.existsSync(indexPath) || !fs.existsSync(commonPath)) {
return;
}
if (fs.existsSync(indexPath)) {
let content = fs.readFileSync(indexPath, 'utf8');
@@ -49,11 +65,9 @@ function fixHttpProxyDeprecation() {
if (indexPatched) {
fs.writeFileSync(indexPath, content, 'utf8');
needsPatch = true;
}
}
// Patch common.js
if (fs.existsSync(commonPath)) {
let content = fs.readFileSync(commonPath, 'utf8');
@@ -69,13 +83,9 @@ function fixHttpProxyDeprecation() {
if (commonPatched) {
fs.writeFileSync(commonPath, content, 'utf8');
needsPatch = true;
}
}
} catch (error) {
// Silently handle errors - functionality is not affected
}
}
// Run the fix
fixHttpProxyDeprecation();
fixHttpProxyDeprecation();
+2 -1
View File
@@ -39,7 +39,7 @@
"lint:electron": "bun run --cwd packages/electron lint",
"clean": "bun run --filter '*' clean",
"changelog-card": "node scripts/changelog-card/generate.mjs",
"postinstall": "patch-package",
"postinstall": "node ./fix-deprecation.js && patch-package",
"dev:web": "bun run --cwd packages/web build:watch",
"dev:web:server": "bun run --cwd packages/web dev:server:watch",
"dev:web:full": "node ./scripts/dev-web-full.mjs",
@@ -51,6 +51,7 @@
"desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs",
"desktop:build": "bun run --cwd packages/desktop build:sidecar && bun run --cwd packages/desktop tauri build",
"electron:dev": "node ./packages/electron/scripts/electron-dev.mjs",
"electron:dev:bundled": "OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1 node ./packages/electron/scripts/electron-dev.mjs",
"electron:build": "bun run --cwd packages/electron package",
"desktop:lint": "bun run --cwd packages/desktop lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
"desktop:type-check": "bun run --cwd packages/desktop type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
@@ -17,6 +17,10 @@ Bind address for the OpenChamber web server. Use `0.0.0.0` to allow access from
Password for the browser UI. Use this when binding outside localhost, using tunnels, or running behind a reverse proxy.
### `OPENCHAMBER_API_ONLY`
Starts OpenChamber in headless mode when set to `true` or `1`. API routes stay available for desktop and mobile clients, but the browser UI is not served.
### `OPENCHAMBER_DATA_DIR`
Overrides the OpenChamber data directory. The default is `~/.config/openchamber`.
@@ -17,6 +17,10 @@ Dirección donde escucha el servidor web de OpenChamber. Usa `0.0.0.0` para perm
Contraseña para la interfaz del navegador. Úsala cuando no te limites a localhost, o cuando uses túneles o un proxy inverso.
### `OPENCHAMBER_API_ONLY`
Inicia OpenChamber en modo headless cuando vale `true` o `1`. Las rutas API siguen disponibles para clientes de escritorio y móviles, pero no se sirve la UI del navegador.
### `OPENCHAMBER_DATA_DIR`
Cambia el directorio de datos de OpenChamber. Por defecto es `~/.config/openchamber`.
@@ -60,8 +60,24 @@ Para proteger la UI, define la contraseña al habilitar el servicio:
OPENCHAMBER_UI_PASSWORD='secret' openchamber startup enable
```
Para un servidor headless que arranca al iniciar sesión y se usa desde apps de escritorio o móviles, añade `--api-only` y un host alcanzable:
```bash
openchamber startup enable --port 3000 --api-only --host 0.0.0.0 --ui-password secret
```
`startup enable` guarda una captura del entorno actual en el servicio para que el arranque se parezca más a ejecutar `openchamber` desde la misma shell. Así conserva tokens de proveedores, `PATH`, configuración del agente SSH y otras variables CLI de auth/config. Usa `--no-env-snapshot` si quieres un entorno de servicio mínimo.
El servicio de inicio recuerda `--port`, `--host`, `--ui-password` y `--api-only`. El reinicio por CLI y el reinicio durante una actualización reutilizan esos ajustes guardados.
Para crear un enlace de conexión para otra app de OpenChamber, usa:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
Ejecuta `openchamber connect-url --help` para ver todas las opciones del enlace, incluidas `--name`, `--lan`, `--server`, `--api-only`, `--ui-password` y `--qr`.
Puedes gestionar túneles de forma independiente para ese servicio en ejecución:
```bash
@@ -24,6 +24,18 @@ OpenChamber recorre los pasos —comprobar la conexión, configurar el remoto, i
Tú decides si guardar las contraseñas de SSH y de UI o introducirlas cada vez. Si la conexión se cae, OpenChamber informa qué paso falló para que puedas arreglarlo; consulta [Acceso remoto](/es/troubleshooting/remote-access/).
## Enlaces de conexión directa
Si una máquina remota ya ejecuta OpenChamber, crea allí un enlace de conexión e impórtalo en **Settings → Remote Instances → Server links**:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
`connect-url` inicia el servidor primero si no hay nada ejecutándose en ese puerto. Añade `--api-only` para un servidor headless, `--lan` para escuchar en la LAN al iniciar, `--ui-password` para proteger el acceso del navegador y `--name` para etiquetar la conexión guardada.
El enlace generado contiene un token de cliente para apps de OpenChamber. Ese token es independiente de la contraseña de la UI del navegador y sobrevive a reinicios hasta que lo revoques o elimines.
## Relacionado
- [OpenCode Server](/es/opencode-server/) — conéctate a un servidor remoto en la web o en VS Code
@@ -30,6 +30,8 @@ ngrok config add-authtoken <your-ngrok-token>
openchamber
```
Si omites este paso, `openchamber tunnel start` puede iniciar automáticamente un servidor CLI. Al hacerlo, puedes pasar opciones del servidor como `--port`, `--host`, `--lan`, `--ui-password` y `--api-only`.
2. Inicia un túnel de Cloudflare:
```bash
@@ -105,6 +107,7 @@ openchamber tunnel stop --port 3000
- un único túnel activo por instancia de OpenChamber (puerto)
- iniciar un nuevo modo/proveedor en la misma instancia reemplaza el túnel anterior
- generar un nuevo enlace de conexión revoca el anterior sin usar
- el autoarranque del túnel conserva flags del servidor como `--ui-password` y `--api-only` en los ajustes de instancia usados por reinicios y actualizaciones
## Relacionado
@@ -17,6 +17,10 @@ OpenChamber 웹 서버가 바인딩할 주소입니다. 다른 컴퓨터에서
브라우저 UI 비밀번호입니다. localhost 밖으로 바인딩하거나 터널, 리버스 프록시를 사용할 때 설정하세요.
### `OPENCHAMBER_API_ONLY`
`true` 또는 `1`이면 OpenChamber를 headless 모드로 시작합니다. 데스크톱과 모바일 클라이언트용 API route는 계속 사용할 수 있지만 브라우저 UI는 제공하지 않습니다.
### `OPENCHAMBER_DATA_DIR`
OpenChamber 데이터 디렉터리를 바꿉니다. 기본값은 `~/.config/openchamber`입니다.
@@ -60,8 +60,24 @@ UI를 보호하려면 서비스를 활성화할 때 비밀번호를 설정하세
OPENCHAMBER_UI_PASSWORD='secret' openchamber startup enable
```
로그인 시 시작되고 데스크톱 또는 모바일 앱에서 사용할 headless 서버라면 `--api-only`와 접근 가능한 host를 함께 지정하세요:
```bash
openchamber startup enable --port 3000 --api-only --host 0.0.0.0 --ui-password secret
```
`startup enable`은 현재 환경의 스냅샷을 서비스에 저장해, 같은 셸에서 `openchamber`를 직접 실행한 것에 더 가깝게 동작하게 합니다. provider 토큰, `PATH`, SSH agent 설정, 기타 CLI auth/config 환경 변수가 유지됩니다. 최소한의 서비스 환경을 원하면 `--no-env-snapshot`을 사용하세요.
시작 서비스는 `--port`, `--host`, `--ui-password`, `--api-only`를 기억합니다. CLI 재시작과 업데이트 재시작은 이 저장된 설정을 다시 사용합니다.
다른 OpenChamber 앱용 연결 링크를 만들려면 다음을 사용하세요:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
`openchamber connect-url --help`를 실행하면 `--name`, `--lan`, `--server`, `--api-only`, `--ui-password`, `--qr` 같은 모든 링크 옵션을 볼 수 있습니다.
실행 중인 이 서비스의 터널은 별도로 관리할 수 있습니다:
```bash
@@ -24,6 +24,18 @@ OpenChamber가 연결 확인, 원격 설정, 서버 시작, 포트 포워딩 단
SSH 및 UI 비밀번호를 저장할지, 매번 입력할지 결정합니다. 연결이 끊기면 OpenChamber가 어느 단계가 실패했는지 알려주므로 고칠 수 있습니다. [Remote access](/ko/troubleshooting/remote-access/)를 참고하세요.
## 직접 연결 링크
원격 머신에서 OpenChamber가 이미 실행 중이면 그 머신에서 연결 링크를 만들고 **Settings → Remote Instances → Server links**에서 가져오세요:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
해당 포트에 서버가 없으면 `connect-url`이 먼저 서버를 시작합니다. Headless 서버에는 `--api-only`, 시작 시 LAN에 바인딩하려면 `--lan`, 브라우저 접근 보호에는 `--ui-password`, 저장된 연결 이름에는 `--name`을 사용하세요.
생성된 링크에는 OpenChamber 앱용 client token이 들어 있습니다. 이 token은 브라우저 UI 비밀번호와 별개이며, 취소하거나 삭제하기 전까지 서버 재시작 후에도 유지됩니다.
## 관련 항목
- [OpenCode Server](/ko/opencode-server/) — 웹이나 VS Code에서 원격 서버에 연결하세요
@@ -30,6 +30,8 @@ ngrok config add-authtoken <your-ngrok-token>
openchamber
```
이 단계를 건너뛰면 `openchamber tunnel start`가 CLI 서버를 자동으로 시작할 수 있습니다. 자동 시작 시 `--port`, `--host`, `--lan`, `--ui-password`, `--api-only` 같은 서버 옵션을 함께 전달할 수 있습니다.
2. Cloudflare 터널을 시작합니다:
```bash
@@ -105,6 +107,7 @@ openchamber tunnel stop --port 3000
- OpenChamber 인스턴스(포트)당 활성 터널은 하나입니다
- 같은 인스턴스에서 새 모드/공급자를 시작하면 이전 터널이 대체됩니다
- 새 연결 링크를 생성하면 사용되지 않은 이전 링크가 무효화됩니다
- 터널 자동 시작은 재시작/업데이트 흐름에서 쓰는 인스턴스 설정에 `--ui-password`, `--api-only` 같은 서버 플래그를 저장합니다
## 관련 문서
@@ -60,8 +60,24 @@ To protect the UI, set the password when enabling the service:
OPENCHAMBER_UI_PASSWORD='secret' openchamber startup enable
```
For a headless server that starts at login and is meant for desktop or mobile clients, include `--api-only` and a reachable host:
```bash
openchamber startup enable --port 3000 --api-only --host 0.0.0.0 --ui-password secret
```
`startup enable` snapshots your current environment into the service so it behaves more like starting `openchamber` from the same shell. This keeps provider tokens, `PATH`, SSH agent settings, and other CLI auth/config variables available. Use `--no-env-snapshot` if you want a minimal service environment.
The startup service remembers `--port`, `--host`, `--ui-password`, and `--api-only`. CLI restart and update restart reuse those saved settings.
To create a connection link for another OpenChamber app, use:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
Run `openchamber connect-url --help` to see all link options, including `--name`, `--lan`, `--server`, `--api-only`, `--ui-password`, and `--qr`.
You can still manage tunnels independently for that running service:
```bash
@@ -17,6 +17,10 @@ Adres, na którym nasłuchuje serwer web OpenChamber. Użyj `0.0.0.0`, aby pozwo
Hasło do interfejsu w przeglądarce. Ustaw je przy dostępie spoza localhost, tunelach albo reverse proxy.
### `OPENCHAMBER_API_ONLY`
Uruchamia OpenChamber w trybie headless, gdy ustawione na `true` lub `1`. Trasy API pozostają dostępne dla klientów desktopowych i mobilnych, ale UI przeglądarki nie jest serwowane.
### `OPENCHAMBER_DATA_DIR`
Nadpisuje katalog danych OpenChamber. Domyślnie jest to `~/.config/openchamber`.
@@ -60,8 +60,24 @@ Aby zabezpieczyć UI, ustaw hasło podczas włączania usługi:
OPENCHAMBER_UI_PASSWORD='secret' openchamber startup enable
```
Dla serwera headless uruchamianego przy logowaniu i używanego przez aplikacje desktopowe lub mobilne dodaj `--api-only` oraz osiągalny host:
```bash
openchamber startup enable --port 3000 --api-only --host 0.0.0.0 --ui-password secret
```
`startup enable` zapisuje migawkę bieżącego środowiska w usłudze, aby uruchomienie było bliższe ręcznemu startowi `openchamber` z tej samej powłoki. Zachowuje to tokeny dostawców, `PATH`, ustawienia agenta SSH i inne zmienne CLI auth/config. Użyj `--no-env-snapshot`, jeśli chcesz minimalne środowisko usługi.
Usługa startowa pamięta `--port`, `--host`, `--ui-password` i `--api-only`. Restart z CLI oraz restart podczas aktualizacji używają tych zapisanych ustawień.
Aby utworzyć link połączenia dla innej aplikacji OpenChamber, użyj:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
Uruchom `openchamber connect-url --help`, aby zobaczyć wszystkie opcje linku, w tym `--name`, `--lan`, `--server`, `--api-only`, `--ui-password` i `--qr`.
Tunelami dla tej działającej usługi możesz zarządzać niezależnie:
```bash
@@ -24,6 +24,18 @@ OpenChamber przeprowadza przez kolejne kroki — sprawdzenie połączenia, skonf
Sam decydujesz, czy zapisać hasła SSH i UI, czy wpisywać je za każdym razem. Jeśli połączenie zostanie zerwane, OpenChamber zgłasza, który krok zawiódł, byś mógł to naprawić — zobacz [Dostęp zdalny](/pl/troubleshooting/remote-access/).
## Bezpośrednie linki połączenia
Jeśli zdalna maszyna już uruchamia OpenChamber, utwórz tam link połączenia i zaimportuj go w **Settings → Remote Instances → Server links**:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
`connect-url` najpierw uruchamia serwer, jeśli nic nie działa na tym porcie. Dodaj `--api-only` dla serwera headless, `--lan` aby nasłuchiwać w LAN przy starcie, `--ui-password` aby chronić dostęp z przeglądarki oraz `--name` aby nazwać zapisane połączenie.
Wygenerowany link zawiera token klienta dla aplikacji OpenChamber. Ten token jest osobny od hasła UI w przeglądarce i przetrwa restarty, dopóki go nie unieważnisz lub usuniesz.
## Powiązane
- [OpenCode Server](/pl/opencode-server/) — połącz się ze zdalnym serwerem w wersji webowej lub VS Code
@@ -30,6 +30,8 @@ ngrok config add-authtoken <your-ngrok-token>
openchamber
```
Jeśli pominiesz ten krok, `openchamber tunnel start` może automatycznie uruchomić serwer CLI. Przy auto-starcie możesz przekazać opcje serwera, takie jak `--port`, `--host`, `--lan`, `--ui-password` i `--api-only`.
2. Uruchom tunel Cloudflare:
```bash
@@ -105,6 +107,7 @@ openchamber tunnel stop --port 3000
- jeden aktywny tunel na instancję OpenChamber (port)
- uruchomienie nowego trybu/dostawcy na tej samej instancji zastępuje poprzedni tunel
- wygenerowanie nowego linku połączenia unieważnia poprzedni nieużyty
- auto-start tunelu zapisuje flagi serwera, takie jak `--ui-password` i `--api-only`, w ustawieniach instancji używanych przez restarty i aktualizacje
## Powiązane
@@ -17,6 +17,10 @@ Endereço onde o servidor web do OpenChamber escuta. Use `0.0.0.0` para permitir
Senha da interface no navegador. Use quando expor fora do localhost, por túnel ou por proxy reverso.
### `OPENCHAMBER_API_ONLY`
Inicia o OpenChamber em modo headless quando definido como `true` ou `1`. As rotas de API continuam disponíveis para clientes desktop e mobile, mas a UI do navegador não é servida.
### `OPENCHAMBER_DATA_DIR`
Altera o diretório de dados do OpenChamber. O padrão é `~/.config/openchamber`.
@@ -60,8 +60,24 @@ Para proteger a UI, defina a senha ao habilitar o serviço:
OPENCHAMBER_UI_PASSWORD='secret' openchamber startup enable
```
Para um servidor headless que inicia no login e é usado por apps desktop ou mobile, inclua `--api-only` e um host acessível:
```bash
openchamber startup enable --port 3000 --api-only --host 0.0.0.0 --ui-password secret
```
`startup enable` salva um snapshot do ambiente atual no serviço para que a inicialização se pareça mais com executar `openchamber` na mesma shell. Isso preserva tokens de provedores, `PATH`, configurações do agente SSH e outras variáveis CLI de auth/config. Use `--no-env-snapshot` se quiser um ambiente de serviço mínimo.
O serviço de inicialização lembra `--port`, `--host`, `--ui-password` e `--api-only`. Reinícios pela CLI e reinícios durante atualização reutilizam essas configurações salvas.
Para criar um link de conexão para outro app OpenChamber, use:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
Execute `openchamber connect-url --help` para ver todas as opções de link, incluindo `--name`, `--lan`, `--server`, `--api-only`, `--ui-password` e `--qr`.
Você ainda pode gerenciar túneis de forma independente para esse serviço em execução:
```bash
@@ -24,6 +24,18 @@ O OpenChamber percorre as etapas — verificando a conexão, configurando o remo
Você decide se quer salvar as senhas SSH e de UI ou inseri-las a cada vez. Se a conexão cair, o OpenChamber informa qual etapa falhou para você corrigi-la — veja [Acesso remoto](/pt-br/troubleshooting/remote-access/).
## Links de conexão direta
Se uma máquina remota já executa OpenChamber, crie um link de conexão nela e importe em **Settings → Remote Instances → Server links**:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
`connect-url` inicia o servidor primeiro se nada estiver rodando nessa porta. Adicione `--api-only` para um servidor headless, `--lan` para escutar na LAN ao iniciar, `--ui-password` para proteger o acesso pelo navegador e `--name` para nomear a conexão salva.
O link gerado contém um token de cliente para apps OpenChamber. Esse token é separado da senha da UI do navegador e sobrevive a reinícios até ser revogado ou removido.
## Relacionado
- [OpenCode Server](/pt-br/opencode-server/) — conecte a um servidor remoto na web ou no VS Code
@@ -30,6 +30,8 @@ ngrok config add-authtoken <your-ngrok-token>
openchamber
```
Se você pular esta etapa, `openchamber tunnel start` pode iniciar automaticamente um servidor CLI. Ao iniciar automaticamente, você pode passar opções do servidor como `--port`, `--host`, `--lan`, `--ui-password` e `--api-only`.
2. Inicie um túnel da Cloudflare:
```bash
@@ -105,6 +107,7 @@ openchamber tunnel stop --port 3000
- um único túnel ativo por instância do OpenChamber (porta)
- iniciar um novo modo/provedor na mesma instância substitui o túnel anterior
- gerar um novo link de conexão revoga o anterior não utilizado
- o auto-start do túnel preserva flags do servidor como `--ui-password` e `--api-only` nas configurações da instância usadas por reinícios e atualizações
## Relacionado
@@ -24,6 +24,18 @@ OpenChamber walks through the steps — checking the connection, setting up the
You decide whether to save the SSH and UI passwords or enter them each time. If the connection drops, OpenChamber reports which step failed so you can fix it — see [Remote access](/troubleshooting/remote-access/).
## Direct connection links
If a remote machine already runs OpenChamber, create a connection link there and import it in **Settings → Remote Instances → Server links**:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
`connect-url` starts the server first if nothing is running on that port. Add `--api-only` for a headless server, `--lan` to bind to the LAN when starting, `--ui-password` to protect browser access, and `--name` to label the saved connection.
The generated link contains a client token for OpenChamber apps. That token is separate from the browser UI password and survives server restarts until you revoke or delete it.
## Related
- [OpenCode Server](/opencode-server/) — connect to a remote server on web or VS Code
+3
View File
@@ -30,6 +30,8 @@ ngrok config add-authtoken <your-ngrok-token>
openchamber
```
If you skip this step, `openchamber tunnel start` can auto-start a CLI server. When auto-starting, you can pass server options such as `--port`, `--host`, `--lan`, `--ui-password`, and `--api-only`.
2. Start a Cloudflare tunnel:
```bash
@@ -105,6 +107,7 @@ openchamber tunnel stop --port 3000
- one active tunnel per OpenChamber instance (port)
- starting a new mode/provider on same instance replaces previous tunnel
- generating a new connect link revokes previous unused one
- tunnel auto-start preserves server flags like `--ui-password` and `--api-only` in the instance settings used by restart/update flows
## Related
@@ -17,6 +17,10 @@ OpenChamber читає ці змінні під час запуску. Для st
Пароль для browser UI. Використовуйте його для доступу не лише з localhost, тунелів або reverse proxy.
### `OPENCHAMBER_API_ONLY`
Запускає OpenChamber у headless mode, якщо встановлено `true` або `1`. API routes залишаються доступними для desktop і mobile clients, але browser UI не віддається.
### `OPENCHAMBER_DATA_DIR`
Перевизначає директорію даних OpenChamber. Типово це `~/.config/openchamber`.
@@ -60,8 +60,24 @@ openchamber startup disable
OPENCHAMBER_UI_PASSWORD='secret' openchamber startup enable
```
Для headless-сервера, який стартує під час входу та використовується desktop або mobile застосунками, додайте `--api-only` і доступний host:
```bash
openchamber startup enable --port 3000 --api-only --host 0.0.0.0 --ui-password secret
```
`startup enable` зберігає знімок поточного середовища в сервісі, щоб запуск був ближчим до ручного запуску `openchamber` з тієї самої shell-сесії. Так зберігаються токени провайдерів, `PATH`, налаштування SSH agent та інші CLI-змінні для auth/config. Використайте `--no-env-snapshot`, якщо потрібне мінімальне середовище сервісу.
Startup-сервіс пам'ятає `--port`, `--host`, `--ui-password` і `--api-only`. CLI restart і restart під час оновлення повторно використовують ці збережені налаштування.
Щоб створити link підключення для іншого застосунку OpenChamber, використайте:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
Запустіть `openchamber connect-url --help`, щоб побачити всі опції link, зокрема `--name`, `--lan`, `--server`, `--api-only`, `--ui-password` і `--qr`.
Тунелями для такого запущеного сервісу можна керувати окремо:
```bash
@@ -24,6 +24,18 @@ OpenChamber проходить через кроки — перевірку з'
Ви вирішуєте, чи зберігати SSH- та UI-паролі, чи вводити їх щоразу. Якщо з'єднання обривається, OpenChamber повідомляє, який крок збоїв, щоб ви могли виправити — див. [Віддалений доступ](/uk/troubleshooting/remote-access/).
## Прямі link підключення
Якщо на віддаленій машині вже запущено OpenChamber, створіть там link підключення й імпортуйте його в **Settings → Remote Instances → Server links**:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
`connect-url` спочатку запускає сервер, якщо на цьому порту нічого не працює. Додайте `--api-only` для headless-сервера, `--lan` для LAN bind під час старту, `--ui-password` для захисту browser access і `--name` для назви збереженого підключення.
Згенерований link містить client token для застосунків OpenChamber. Цей token окремий від пароля browser UI і зберігається після рестартів, доки ви його не відкличете або не видалите.
## Пов'язане
- [OpenCode Server](/uk/opencode-server/) — підключайтеся до віддаленого сервера у вебі чи VS Code
@@ -30,6 +30,8 @@ ngrok config add-authtoken <your-ngrok-token>
openchamber
```
Якщо пропустити цей крок, `openchamber tunnel start` може автоматично запустити CLI server. Під час auto-start можна передати server options: `--port`, `--host`, `--lan`, `--ui-password` і `--api-only`.
2. Запустіть тунель Cloudflare:
```bash
@@ -17,6 +17,10 @@ OpenChamber web 服务器监听的地址。使用 `0.0.0.0` 可允许其他机
浏览器 UI 的密码。当你绑定到 localhost 之外、使用隧道或反向代理时,请设置它。
### `OPENCHAMBER_API_ONLY`
设置为 `true` 或 `1` 时,以 headless 模式启动 OpenChamber。桌面和移动客户端仍可使用 API 路由,但不会提供浏览器 UI。
### `OPENCHAMBER_DATA_DIR`
覆盖 OpenChamber 数据目录。默认是 `~/.config/openchamber`。
@@ -60,8 +60,24 @@ openchamber startup disable
OPENCHAMBER_UI_PASSWORD='secret' openchamber startup enable
```
如果要在登录时启动一个供桌面或移动应用使用的 headless 服务器,请加上 `--api-only` 和可访问的 host
```bash
openchamber startup enable --port 3000 --api-only --host 0.0.0.0 --ui-password secret
```
`startup enable` 会把当前环境快照保存到服务中,让启动行为更接近你在同一个 shell 中手动运行 `openchamber`。这会保留提供商 token、`PATH`、SSH agent 设置以及其他 CLI auth/config 环境变量。如果你想要最小化的服务环境,请使用 `--no-env-snapshot`。
启动服务会记住 `--port`、`--host`、`--ui-password` 和 `--api-only`。CLI restart 和更新期间的 restart 会复用这些已保存设置。
要为另一个 OpenChamber 应用创建连接链接,请使用:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
运行 `openchamber connect-url --help` 可查看所有链接选项,包括 `--name`、`--lan`、`--server`、`--api-only`、`--ui-password` 和 `--qr`。
你仍然可以独立管理这个运行中服务的隧道:
```bash
@@ -24,6 +24,18 @@ OpenChamber 会引导你完成各个步骤 — 检查连接、设置远程、启
你来决定是保存 SSH 和 UI 密码,还是每次都输入它们。如果连接断开,OpenChamber 会报告哪一步失败了,以便你修复它 — 参阅 [远程访问](/zh-cn/troubleshooting/remote-access/)。
## 直接连接链接
如果远程机器已经在运行 OpenChamber,请在那台机器上创建连接链接,然后在 **Settings → Remote Instances → Server links** 中导入:
```bash
openchamber connect-url --port 3000 --server http://your-host:3000 --qr
```
如果该端口上没有服务器,`connect-url` 会先启动服务器。使用 `--api-only` 可启动 headless 服务器,`--lan` 可在启动时绑定到 LAN`--ui-password` 可保护浏览器访问,`--name` 可为保存的连接命名。
生成的链接包含 OpenChamber 应用使用的 client token。这个 token 独立于浏览器 UI 密码,并会在服务器重启后继续有效,直到你撤销或删除它。
## 相关内容
- [OpenCode Server](/zh-cn/opencode-server/) — 在网页端或 VS Code 中连接到远程服务器
@@ -30,6 +30,8 @@ ngrok config add-authtoken <your-ngrok-token>
openchamber
```
如果跳过这一步,`openchamber tunnel start` 可以自动启动 CLI 服务器。自动启动时可以传入服务器选项,例如 `--port`、`--host`、`--lan`、`--ui-password` 和 `--api-only`。
2. 启动 Cloudflare 隧道:
```bash
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -12,6 +12,8 @@ const readArgValue = (name) => {
};
const localOrigin = readArgValue('--openchamber-local-origin');
const apiBaseUrl = readArgValue('--openchamber-api-base-url');
const clientToken = readArgValue('--openchamber-client-token');
const homeDirectory = readArgValue('--openchamber-home');
const macosMajorRaw = readArgValue('--openchamber-macos-major');
const macosMajor = Number.parseInt(macosMajorRaw, 10);
@@ -22,10 +24,9 @@ const macosMajor = Number.parseInt(macosMajorRaw, 10);
// Remote UIs still need it so isDesktopShell() returns true and the
// window renders with desktop affordances (DesktopHostSwitcher,
// title bar offsets, etc.). Expose unconditionally.
// - __TAURI__ is the IPC channel to the main process. Remote pages must
// not get it — otherwise any page loaded via DesktopHostSwitcher could
// read local files, open apps, relaunch, etc. Expose only on local
// pages (loopback / state.localOrigin / file:// for dev).
// - __TAURI__ is the IPC channel to the main process. The compatibility
// shim is exposed broadly, but privileged commands are gated in main.mjs.
// Local-only globals below stay limited to packaged UI / exact localOrigin.
// Everything driven by localOrigin (home dir, macOS hints) also stays
// local-only since it leaks info about the Electron host machine.
const currentOrigin = (() => {
@@ -35,10 +36,9 @@ const currentOrigin = (() => {
return '';
}
})();
const isLoopbackOrigin = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(currentOrigin);
const isLocalPage = currentOrigin === 'null'
|| isLoopbackOrigin
|| (localOrigin && currentOrigin === localOrigin);
const isLocalPage = currentOrigin !== 'null'
&& (currentOrigin === 'openchamber-ui://app'
|| (localOrigin && currentOrigin === localOrigin));
// Remote pages need __OPENCHAMBER_LOCAL_ORIGIN__ so the HostSwitcher knows
// the URL of the Local entry (isDesktopLocalOriginActive() falls back to
@@ -49,6 +49,14 @@ if (localOrigin) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_LOCAL_ORIGIN__', localOrigin);
}
if (apiBaseUrl) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_API_BASE_URL__', apiBaseUrl);
}
if (clientToken && isLocalPage) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_CLIENT_TOKEN__', clientToken);
}
// Home directory leaks the OS username — keep local-only. Remote pages
// operate on the REMOTE server's filesystem, local home is irrelevant
// (and would be misleading if consumed as a workspace hint).
+42 -13
View File
@@ -45,6 +45,25 @@ function spawnProcess(command, args, options = {}) {
});
}
function runProcess(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: repoRoot,
env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1' },
stdio: 'inherit',
...options,
});
child.on('error', reject);
child.on('exit', (code, signal) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 'null'} signal ${signal ?? 'none'}`));
});
});
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
if (!child || child.exitCode !== null || child.signalCode !== null) {
@@ -159,23 +178,33 @@ async function stopChildTree(child) {
}
async function main() {
const hmrApiPort = String(await findAvailablePort(preferredHmrApiPort));
const hmrUiPort = String(await findAvailablePort(preferredHmrUiPort));
const useBundledUi = process.env.OPENCHAMBER_ELECTRON_USE_BUNDLED_UI === '1';
let devServer = null;
let hmrApiPort = '';
let hmrUiPort = '';
if (useBundledUi) {
await runProcess('bun', ['run', '--cwd', 'packages/electron', 'build:web-assets']);
} else {
hmrApiPort = String(await findAvailablePort(preferredHmrApiPort));
hmrUiPort = String(await findAvailablePort(preferredHmrUiPort));
devServer = spawnProcess('node', ['./scripts/dev-web-hmr.mjs'], {
env: {
...process.env,
OPENCHAMBER_ELECTRON_DEV: '1',
OPENCHAMBER_HMR_UI_PORT: hmrUiPort,
OPENCHAMBER_HMR_API_PORT: hmrApiPort,
OPENCHAMBER_DISABLE_PWA_DEV: '1',
},
});
}
const devServer = spawnProcess('node', ['./scripts/dev-web-hmr.mjs'], {
env: {
...process.env,
OPENCHAMBER_ELECTRON_DEV: '1',
OPENCHAMBER_HMR_UI_PORT: hmrUiPort,
OPENCHAMBER_HMR_API_PORT: hmrApiPort,
OPENCHAMBER_DISABLE_PWA_DEV: '1',
},
});
const electron = spawnProcess('npx', ['electron', './main.mjs'], {
cwd: electronDir,
env: {
...process.env,
OPENCHAMBER_ELECTRON_DEV: '1',
...(useBundledUi ? { OPENCHAMBER_ELECTRON_USE_BUNDLED_UI: '1' } : {}),
OPENCHAMBER_HMR_UI_PORT: hmrUiPort,
OPENCHAMBER_HMR_API_PORT: hmrApiPort,
OPENCHAMBER_DISABLE_PWA_DEV: '1',
@@ -200,9 +229,9 @@ async function main() {
void teardown(code ?? 1);
};
devServer.on('exit', onChildExit('dev server'));
devServer?.on('exit', onChildExit('dev server'));
electron.on('exit', onChildExit('electron'));
devServer.on('error', (error) => {
devServer?.on('error', (error) => {
console.error('[electron:dev] failed to start dev server:', error);
void teardown(1);
});
+32 -3
View File
@@ -32,6 +32,9 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { SyncProvider } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
@@ -51,6 +54,7 @@ import { useI18n } from '@/lib/i18n';
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { SyncAppEffects } from '@/apps/AppEffects';
import { useAppFontEffects } from '@/apps/useAppFontEffects';
import { resetStreamingState } from '@/sync/streaming';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
@@ -215,6 +219,7 @@ function App({ apis }: AppProps) {
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0);
const [manualInitRetrying, setManualInitRetrying] = React.useState(false);
const wideChatLayoutEnabled = useUIStore((state) => state.wideChatLayoutEnabled);
const mobileKeyboardMode = useUIStore((state) => state.mobileKeyboardMode);
@@ -249,6 +254,30 @@ function App({ apis }: AppProps) {
setIsVSCodeRuntime(apis.runtime.isVSCode);
}, [apis.runtime.isVSCode]);
React.useEffect(() => {
return subscribeRuntimeEndpointChanged((detail) => {
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
disposeTerminalInputTransport();
opencodeClient.reconnectToRuntimeBaseUrl();
useConfigStore.setState({
providers: [],
agents: [],
isConnected: false,
isInitialized: false,
connectionPhase: 'connecting',
lastDisconnectReason: null,
});
useProjectsStore.getState().resetForRuntimeSwitch();
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState();
setRuntimeEndpointEpoch((epoch) => epoch + 1);
setInitRetryExhausted(false);
setInitRetryEpoch((epoch) => epoch + 1);
});
}, []);
React.useEffect(() => {
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
return () => {
@@ -337,7 +366,7 @@ function App({ apis }: AppProps) {
let cancelled = false;
const run = async () => {
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null);
if (!res || !res.ok || cancelled) return;
const data = (await res.json().catch(() => null)) as null | {
planModeExperimentalEnabled?: unknown;
@@ -810,7 +839,7 @@ function App({ apis }: AppProps) {
if (embeddedSessionChat) {
return (
<ErrorBoundary>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<SyncProvider key={runtimeEndpointEpoch} sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
@@ -853,7 +882,7 @@ function App({ apis }: AppProps) {
return (
<ErrorBoundary>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<SyncProvider key={runtimeEndpointEpoch} sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<FireworksProvider>
<VoiceProvider>
+7 -2
View File
@@ -15,6 +15,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitStore } from '@/stores/useGitStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { SyncProvider, useSessions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { SyncRuntimeEffects } from './AppEffects';
import { useAppFontEffects } from './useAppFontEffects';
import { useMiniChatKeyboardShortcuts } from '@/hooks/useMiniChatKeyboardShortcuts';
@@ -65,6 +66,7 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
const loadAgents = useConfigStore((state) => state.loadAgents);
const providersCount = useConfigStore((state) => state.providers.length);
const agentsCount = useConfigStore((state) => state.agents.length);
const sync = useSync();
React.useEffect(() => {
void initializeApp();
@@ -130,11 +132,14 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
return;
}
const session = sessions.find((entry) => entry.id === config.sessionId);
if (!session) return;
if (!session) {
void sync.ensureSessionRenderable(config.sessionId);
return;
}
const directory = (session as { directory?: string | null }).directory ?? config.directory;
setCurrentSession(config.sessionId, directory);
sessionBootstrappedRef.current = true;
}, [config, currentSessionId, sessions, setCurrentSession]);
}, [config, currentSessionId, sessions, setCurrentSession, sync]);
React.useEffect(() => {
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
+468
View File
@@ -0,0 +1,468 @@
import React from 'react';
import {
RiFileTextLine,
RiGitBranchLine,
RiMenuLine,
RiMore2Line,
RiSettings3Line,
} from '@remixicon/react';
import { ChatView } from '@/components/views/ChatView';
import { SettingsView } from '@/components/views/SettingsView';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useRouter } from '@/hooks/useRouter';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
import type { RuntimeAPIs } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import type { WorktreeMetadata } from '@/types/worktree';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { SyncProvider, useSession } from '@/sync/sync-context';
import { SyncAppEffects } from './AppEffects';
import { MobileChangesSurface } from './MobileChangesSurface';
import { MobileFilesSurface } from './MobileFilesSurface';
import { MobileSessionsSheet } from './MobileSessionsSheet';
import { MobileSurfaceShell } from './MobileSurfaceShell';
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
import { useAppFontEffects } from './useAppFontEffects';
const MOBILE_SETTINGS_PAGES = [
'appearance',
'chat',
'notifications',
'sessions',
'git',
'magic-prompts',
'behavior',
'mcp',
'providers',
'usage',
'voice',
] as const;
type MobileAppProps = {
apis: RuntimeAPIs;
};
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
const getProjectLabel = (path: string): string => {
const normalized = normalizePath(path);
if (!normalized) return '';
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
};
type OverflowItem = {
key: 'files' | 'changes' | 'settings';
Icon: typeof RiFileTextLine;
label: string;
badge?: number;
onSelect: () => void;
};
const MobileOverflowMenu: React.FC<{
open: boolean;
onClose: () => void;
items: OverflowItem[];
}> = ({ open, onClose, items }) => {
const { t } = useI18n();
React.useEffect(() => {
if (!open) return;
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose, open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
<button
type="button"
className="absolute inset-0 cursor-default bg-[rgb(0_0_0_/_0.25)]"
aria-label={t('mobile.surface.closeAria')}
onClick={onClose}
/>
<div
className="absolute right-2 top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/40 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
role="menu"
style={{ animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)' }}
>
{items.map((item, index) => (
<button
key={item.key}
type="button"
role="menuitem"
className={cn(
'flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
index > 0 && 'border-t border-border/30',
)}
style={{ touchAction: 'manipulation' }}
onClick={() => {
item.onSelect();
onClose();
}}
>
<item.Icon className="size-5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{item.label}</span>
{item.badge && item.badge > 0 ? (
<span className="inline-flex size-2 shrink-0 rounded-full bg-primary" aria-hidden />
) : null}
</button>
))}
</div>
<style>{`@keyframes mobile-menu-in { from { opacity: 0; transform: translateY(-6px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
</div>
);
};
const MobileHeader: React.FC<{
onOpenSessions: () => void;
onOpenMenu: () => void;
}> = ({ onOpenSessions, onOpenMenu }) => {
const { t } = useI18n();
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const projects = useProjectsStore((state) => state.projects);
const currentSession = useSession(currentSessionId, currentDirectory || undefined);
const projectLabel = React.useMemo(() => {
const directory = normalizePath(currentDirectory);
if (!directory) return t('mobile.header.noProject');
const project = projects.find((entry) => {
const projectPath = normalizePath(entry.path);
return directory === projectPath || directory.startsWith(`${projectPath}/`);
});
return project?.label?.trim() || getProjectLabel(project?.path || directory);
}, [currentDirectory, projects, t]);
const sessionTitle = currentSession?.title?.trim();
const primaryLabel = sessionTitle || projectLabel;
const secondaryLabel = sessionTitle ? projectLabel : currentSessionId ? t('mobile.sessions.untitled') : '';
return (
<header
className="relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
>
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.sessions.openSheetAria')}
onClick={onOpenSessions}
style={{ touchAction: 'manipulation' }}
>
<RiMenuLine className="size-5" />
</button>
<button
type="button"
className="flex min-w-0 flex-1 items-center rounded-full px-2 py-1.5 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.sessions.openSheetAria')}
onClick={onOpenSessions}
style={{ touchAction: 'manipulation' }}
>
<span className="flex min-w-0 flex-1 flex-col leading-tight">
<span className="block truncate typography-ui-label text-foreground">{primaryLabel}</span>
{secondaryLabel ? (
<span className="block truncate typography-micro text-muted-foreground">{secondaryLabel}</span>
) : null}
</span>
</button>
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.header.openMenuAria')}
onClick={onOpenMenu}
style={{ touchAction: 'manipulation' }}
>
<RiMore2Line className="size-5" />
</button>
</div>
</header>
);
};
const MobileShell: React.FC = () => {
const { t } = useI18n();
const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false);
const [filesOpen, setFilesOpen] = React.useState(false);
const [changesOpen, setChangesOpen] = React.useState(false);
const [settingsOpen, setSettingsOpen] = React.useState(false);
const [overflowOpen, setOverflowOpen] = React.useState(false);
// When set, the Changes surface opens directly into the per-file diff for this path.
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const gitStatus = useGitStatus(normalizePath(currentDirectory) || null);
const dirtyChangeCount = gitStatus?.files?.length ?? 0;
const mobileActions = React.useMemo<MobileAppActions>(
() => ({
openChanges: ({ diffPath, staged } = {}) => {
setPendingChangesDiff(diffPath ? { path: diffPath, staged: staged === true } : null);
setChangesOpen(true);
},
openFiles: () => setFilesOpen(true),
openSettings: () => setSettingsOpen(true),
}),
[],
);
const closeChanges = React.useCallback(() => {
setChangesOpen(false);
setPendingChangesDiff(null);
}, []);
const overflowItems: OverflowItem[] = React.useMemo(
() => [
{
key: 'files',
Icon: RiFileTextLine,
label: t('mobile.menu.files'),
onSelect: () => setFilesOpen(true),
},
{
key: 'changes',
Icon: RiGitBranchLine,
label: t('mobile.menu.changes'),
badge: dirtyChangeCount,
onSelect: () => setChangesOpen(true),
},
{
key: 'settings',
Icon: RiSettings3Line,
label: t('mobile.menu.settings'),
onSelect: () => setSettingsOpen(true),
},
],
[dirtyChangeCount, t],
);
return (
<DedicatedMobileAppProvider actions={mobileActions}>
<div
className="main-content-safe-area flex h-[100dvh] flex-col bg-background text-foreground"
data-page-scroll-lock="true"
>
<MobileHeader
onOpenSessions={() => setSessionsSheetOpen(true)}
onOpenMenu={() => setOverflowOpen(true)}
/>
<main className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
<ErrorBoundary>
<ChatView />
</ErrorBoundary>
</main>
<MobileOverflowMenu
open={overflowOpen}
onClose={() => setOverflowOpen(false)}
items={overflowItems}
/>
{sessionsSheetOpen ? (
<MobileSessionsSheet open={sessionsSheetOpen} onOpenChange={setSessionsSheetOpen} />
) : null}
<MobileSurfaceShell
open={filesOpen}
onClose={() => setFilesOpen(false)}
ariaLabel={t('mobile.menu.files')}
headerless
>
<ErrorBoundary>
<MobileFilesSurface onClose={() => setFilesOpen(false)} />
</ErrorBoundary>
</MobileSurfaceShell>
<MobileSurfaceShell
open={changesOpen}
onClose={closeChanges}
ariaLabel={t('mobile.menu.changes')}
headerless
>
<ErrorBoundary>
<MobileChangesSurface
onClose={closeChanges}
initialDiffPath={pendingChangesDiff?.path ?? null}
initialDiffStaged={pendingChangesDiff?.staged === true}
/>
</ErrorBoundary>
</MobileSurfaceShell>
<MobileSurfaceShell
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
ariaLabel={t('mobile.menu.settings')}
headerless
>
<ErrorBoundary>
<SettingsView
forceMobile
isWindowed
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
onClose={() => setSettingsOpen(false)}
/>
</ErrorBoundary>
</MobileSurfaceShell>
</div>
</DedicatedMobileAppProvider>
);
};
export function MobileApp({ apis }: MobileAppProps) {
const initializeApp = useConfigStore((state) => state.initializeApp);
const isInitialized = useConfigStore((state) => state.isInitialized);
const isConnected = useConfigStore((state) => state.isConnected);
const providersCount = useConfigStore((state) => state.providers.length);
const agentsCount = useConfigStore((state) => state.agents.length);
const loadProviders = useConfigStore((state) => state.loadProviders);
const loadAgents = useConfigStore((state) => state.loadAgents);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const error = useSessionUIStore((state) => state.error);
const clearError = useSessionUIStore((state) => state.clearError);
const setIsMobile = useUIStore((state) => state.setIsMobile);
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
const projects = useProjectsStore((state) => state.projects);
React.useEffect(() => {
registerRuntimeAPIs(apis);
return () => registerRuntimeAPIs(null);
}, [apis]);
React.useEffect(() => {
setIsMobile(true);
}, [setIsMobile]);
React.useEffect(() => {
void initializeApp();
}, [initializeApp]);
React.useEffect(() => {
if (!isConnected) return;
if (providersCount === 0) void loadProviders();
if (agentsCount === 0) void loadAgents();
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
React.useEffect(() => {
if (!isConnected) return;
opencodeClient.setDirectory(currentDirectory);
}, [currentDirectory, isConnected]);
React.useEffect(() => {
void refreshGitHubAuthStatus(apis.github, { force: true });
}, [apis.github, refreshGitHubAuthStatus]);
// Discover all worktrees for every known project so the draft session's
// worktree/branch dropdown can list every available branch — not only the
// current one. Mirrors ElectronMiniChatApp + desktop SessionSidebar.
React.useEffect(() => {
if (projects.length === 0) return;
let cancelled = false;
const run = async () => {
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
const allWorktrees: WorktreeMetadata[] = [];
await Promise.all(
projects.map(async (project) => {
const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '');
if (!projectPath) return;
try {
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo =
cachedIsGitRepo ?? (await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath)));
if (!isGitRepo) return;
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) return;
worktreesByProject.set(projectPath, worktrees);
allWorktrees.push(...worktrees);
} catch {
// Worktree discovery is best-effort; draft selector falls back to the project root.
}
}),
);
if (cancelled) return;
useSessionUIStore.setState({
availableWorktrees: allWorktrees,
availableWorktreesByProject: worktreesByProject,
});
};
void run();
return () => {
cancelled = true;
};
}, [projects]);
React.useEffect(() => {
let cancelled = false;
const run = async () => {
const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null);
if (!res || !res.ok || cancelled) return;
const data = (await res.json().catch(() => null)) as null | { planModeExperimentalEnabled?: unknown };
if (!data || cancelled) return;
const raw = data.planModeExperimentalEnabled;
setPlanModeEnabled(raw === true || raw === 1 || raw === '1' || raw === 'true');
};
void run();
return () => {
cancelled = true;
};
}, [setPlanModeEnabled]);
React.useEffect(() => {
if (!error) return;
const timeout = window.setTimeout(() => clearError(), 5000);
return () => window.clearTimeout(timeout);
}, [clearError, error]);
useAppFontEffects();
usePushVisibilityBeacon({ enabled: true });
useWindowTitle();
useRouter();
return (
<ErrorBoundary>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full bg-background text-foreground">
<SyncAppEffects embeddedBackgroundWorkEnabled={isInitialized} />
<MobileShell />
<Toaster />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
</SyncProvider>
</ErrorBoundary>
);
}
@@ -0,0 +1,617 @@
import React from 'react';
import { RiArrowLeftLine, RiCloseLine, RiGitBranchLine, RiLoader4Line } from '@remixicon/react';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
import { CommitSection } from '@/components/views/git/CommitSection';
import { SyncActions } from '@/components/views/git/SyncActions';
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import type { GitStatus } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi';
import type { GitRemote } from '@/lib/gitApi';
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
import {
useGitStore,
useGitStatus,
useIsGitRepo,
useGitLoadingStatus,
} from '@/stores/useGitStore';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
const indexStatus = file.index?.trim();
return Boolean(indexStatus && indexStatus !== '?');
};
const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => {
const workingStatus = file.working_dir?.trim();
const indexStatus = file.index?.trim();
return Boolean(workingStatus || indexStatus === '?');
};
const diffCacheKey = (path: string, staged: boolean): string => staged ? `${path}\u0000staged` : path;
type MobileChangesSurfaceProps = {
/** When provided, the list header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
onClose?: () => void;
/**
* When set (and non-null), the surface opens directly into the per-file diff view for this
* relative path. Updating it (incl. setting it to a different path while open) routes the
* surface to that diff. Setting it back to null leaves the user on the current internal route.
*/
initialDiffPath?: string | null;
initialDiffStaged?: boolean;
};
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const currentDirectory = normalizePath(useEffectiveDirectory() ?? null);
const status = useGitStatus(currentDirectory || null);
const isGitRepo = useIsGitRepo(currentDirectory || null);
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureAll = useGitStore((state) => state.ensureAll);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
const getDiff = useGitStore((state) => state.getDiff);
const setDiff = useGitStore((state) => state.setDiff);
const [route, setRoute] = React.useState<{ type: 'list' } | { type: 'diff'; path: string; staged: boolean }>(
() => (initialDiffPath ? { type: 'diff', path: initialDiffPath, staged: initialDiffStaged } : { type: 'list' }),
);
// Allow the host (MobileApp) to push us into a specific diff when the surface
// is reopened or when an external trigger (e.g. PendingChangesBar tap) requests
// a different file mid-session.
React.useEffect(() => {
if (!initialDiffPath) return;
setRoute((current) => (
current.type === 'diff' && current.path === initialDiffPath && current.staged === initialDiffStaged
? current
: { type: 'diff', path: initialDiffPath, staged: initialDiffStaged }
));
}, [initialDiffPath, initialDiffStaged]);
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
const [commitMessage, setCommitMessage] = React.useState('');
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>([]);
const [visibleChangePaths, setVisibleChangePaths] = React.useState<string[]>([]);
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const changeEntries = React.useMemo(() => {
const files = status?.files ?? [];
const unique = new Map<string, (typeof files)[number]>();
for (const file of files) {
unique.set(file.path, file);
}
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
}, [status?.files]);
const stagedChangeEntries = React.useMemo(
() => changeEntries.filter(isStagedStatusFile),
[changeEntries],
);
const unstagedChangeEntries = React.useMemo(
() => changeEntries.filter(isUnstagedStatusFile),
[changeEntries],
);
const effectiveRemotes = React.useMemo<GitRemote[]>(() => {
if (remotes.length > 0) return remotes;
const trackingRemote = status?.tracking?.includes('/') ? status.tracking.split('/')[0] : null;
if (trackingRemote || remoteUrl) {
return [{ name: trackingRemote || 'origin', fetchUrl: remoteUrl ?? '', pushUrl: remoteUrl ?? '' }];
}
return [];
}, [remoteUrl, remotes, status?.tracking]);
const selectedDiff = useGitStore(React.useCallback((state) => {
if (!currentDirectory || route.type !== 'diff') return null;
return state.directories.get(currentDirectory)?.diffCache.get(diffCacheKey(route.path, route.staged)) ?? null;
}, [currentDirectory, route]));
const selectedFileEntry = React.useMemo(() => {
if (route.type !== 'diff') return null;
return changeEntries.find((entry) => entry.path === route.path) ?? null;
}, [changeEntries, route]);
const refreshStatusAndBranches = React.useCallback(async (showErrors = true) => {
if (!currentDirectory) return;
try {
await Promise.all([
fetchStatus(currentDirectory, git),
fetchBranches(currentDirectory, git),
]);
} catch (error) {
if (showErrors) {
toast.error(error instanceof Error ? error.message : t('gitView.toast.refreshRepositoryFailed'));
}
}
}, [currentDirectory, fetchBranches, fetchStatus, git, t]);
const refreshRemotes = React.useCallback(async () => {
if (!currentDirectory) {
setRemotes([]);
setRemoteUrl(null);
return;
}
try {
const [remoteList, url] = await Promise.all([
git.getRemotes(currentDirectory).catch(() => []),
git.getRemoteUrl ? git.getRemoteUrl(currentDirectory).catch(() => null) : Promise.resolve(null),
]);
setRemotes(remoteList);
setRemoteUrl(url);
} catch {
setRemotes([]);
setRemoteUrl(null);
}
}, [currentDirectory, git]);
React.useEffect(() => {
if (!currentDirectory) return;
setActiveDirectory(currentDirectory);
void ensureAll(currentDirectory, git);
}, [currentDirectory, ensureAll, git, setActiveDirectory]);
React.useEffect(() => {
void refreshRemotes();
}, [refreshRemotes]);
React.useEffect(() => {
if (!currentDirectory || changeEntries.length === 0) return;
const orderedPaths = Array.from(new Set([
...stagedChangeEntries.map((entry) => entry.path),
...visibleChangePaths,
...changeEntries.slice(0, 20).map((entry) => entry.path),
])).filter(Boolean);
if (orderedPaths.length === 0) return;
const timeoutId = window.setTimeout(() => {
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: 40 });
}, 120);
return () => window.clearTimeout(timeoutId);
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
React.useEffect(() => {
if (route.type !== 'diff') {
setDiffLoadError(null);
return;
}
const cacheKey = diffCacheKey(route.path, route.staged);
if (!currentDirectory || getDiff(currentDirectory, cacheKey)) {
setDiffLoadError(null);
return;
}
let cancelled = false;
setDiffLoadError(null);
void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined })
.then((response) => {
if (cancelled) return;
setDiff(currentDirectory, cacheKey, {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
});
})
.catch((error) => {
if (cancelled) return;
setDiffLoadError(error instanceof Error ? error.message : String(error));
});
return () => {
cancelled = true;
};
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff]);
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
if (!currentDirectory) return;
setSyncAction(action);
try {
const getPullOptions = (pullRemote: GitRemote) => {
const trackingPrefix = `${pullRemote.name}/`;
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
? status.tracking.slice(trackingPrefix.length)
: undefined;
return { remote: pullRemote.name, branch: trackedBranch, rebase: true };
};
if (action === 'fetch') {
if (!remote) throw new Error(t('mobile.changes.noRemote'));
await git.gitFetch(currentDirectory, { remote: remote.name });
toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name }));
} else if (action === 'sync') {
if (!remote) throw new Error(t('mobile.changes.noRemote'));
await git.gitFetch(currentDirectory, { remote: remote.name });
const afterFetch = await git.getGitStatus(currentDirectory);
if ((afterFetch.behind ?? 0) > 0) {
if ((afterFetch.files?.length ?? 0) > 0) {
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
return;
}
await git.gitPull(currentDirectory, getPullOptions(remote));
}
const afterPull = await git.getGitStatus(currentDirectory);
if ((afterPull.ahead ?? 0) > 0) {
await git.gitPush(currentDirectory);
}
toast.success(t('gitView.toast.alreadyUpToDate'));
}
await refreshStatusAndBranches(false);
await refreshRemotes();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('gitView.toast.syncActionFailed', { action: t('gitView.sync.syncChanges') }));
} finally {
setSyncAction(null);
}
};
const moveChangePaths = React.useCallback(async (paths: string[], direction: 'stage' | 'unstage') => {
if (!currentDirectory || paths.length === 0) return;
try {
if (direction === 'stage') {
if (paths.length > 1) await stageGitFiles(currentDirectory, paths);
else await stageGitFile(currentDirectory, paths[0]);
} else {
if (paths.length > 1) await unstageGitFiles(currentDirectory, paths);
else await unstageGitFile(currentDirectory, paths[0]);
}
await refreshStatusAndBranches(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : direction === 'stage'
? t('gitView.toast.stageFileFailed')
: t('gitView.toast.unstageFileFailed'));
}
}, [currentDirectory, refreshStatusAndBranches, t]);
const handleViewChangeDiff = React.useCallback((path: string, staged = false) => {
setRoute({ type: 'diff', path, staged });
}, []);
const handleRevertFile = React.useCallback(async (filePath: string) => {
if (!currentDirectory) return;
setRevertingPaths((previous) => new Set(previous).add(filePath));
try {
await git.revertGitFile(currentDirectory, filePath);
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
await refreshStatusAndBranches(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : t('gitView.toast.revertFailed'));
} finally {
setRevertingPaths((previous) => {
const next = new Set(previous);
next.delete(filePath);
return next;
});
}
}, [currentDirectory, git, refreshStatusAndBranches, t]);
const handleRevertAll = React.useCallback(async (paths: string[]) => {
if (!currentDirectory || paths.length === 0 || isRevertingAll) return;
const uniquePaths = Array.from(new Set(paths));
setIsRevertingAll(true);
setRevertingPaths(new Set(uniquePaths));
try {
await Promise.all(uniquePaths.map((filePath) => git.revertGitFile(currentDirectory, filePath)));
await refreshStatusAndBranches(false);
toast.success(uniquePaths.length === 1
? t('gitView.toast.revertedFilesSingle', { count: uniquePaths.length })
: t('gitView.toast.revertedFilesPlural', { count: uniquePaths.length }));
} catch (error) {
toast.error(error instanceof Error ? error.message : t('gitView.toast.revertFailed'));
} finally {
setRevertingPaths(new Set());
setIsRevertingAll(false);
}
}, [currentDirectory, git, isRevertingAll, refreshStatusAndBranches, t]);
const handleInsertHighlights = React.useCallback((highlights: string[]) => {
const normalized = highlights.map((text) => text.trim()).filter(Boolean);
if (normalized.length === 0) {
setGeneratedHighlights([]);
return;
}
setCommitMessage((current) => `${current.trim()}${current.trim() ? '\n\n' : ''}${normalized.join('\n')}`.trim());
setGeneratedHighlights([]);
}, []);
const handleGenerateCommitMessage = React.useCallback(async () => {
if (!currentDirectory) return;
const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort();
if (selectedFilePaths.length === 0) {
toast.error(t('gitView.toast.selectFileToDescribe'));
return;
}
setIsGeneratingMessage(true);
try {
const { message } = await generateCommitMessage(currentDirectory, selectedFilePaths);
setCommitMessage(message.subject?.trim() ?? '');
setGeneratedHighlights(Array.isArray(message.highlights) ? message.highlights : []);
} catch (error) {
toast.error(error instanceof Error ? error.message : t('gitView.toast.generateCommitMessageFailed'));
} finally {
setIsGeneratingMessage(false);
}
}, [currentDirectory, stagedChangeEntries, t]);
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
if (!currentDirectory) return;
if (!commitMessage.trim()) {
toast.error(t('gitView.toast.enterCommitMessage'));
return;
}
const filesToCommit = stagedChangeEntries.map((file) => file.path).sort();
if (filesToCommit.length === 0) {
toast.error(t('gitView.toast.selectFileToCommit'));
return;
}
setCommitAction(options.pushAfter ? 'commitAndPush' : 'commit');
try {
await git.createGitCommit(currentDirectory, commitMessage.trim(), { files: filesToCommit });
toast.success(t('gitView.toast.commitCreated'));
setCommitMessage('');
setGeneratedHighlights([]);
if (options.pushAfter) {
const trackingRemoteName = status?.tracking?.split('/')[0];
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
if (!remote) throw new Error(t('mobile.changes.noRemote'));
setSyncAction('sync');
const trackingPrefix = `${remote.name}/`;
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
? status.tracking.slice(trackingPrefix.length)
: undefined;
await git.gitFetch(currentDirectory, { remote: remote.name });
const afterFetch = await git.getGitStatus(currentDirectory);
if ((afterFetch.behind ?? 0) > 0) {
await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true });
}
const afterPull = await git.getGitStatus(currentDirectory);
if ((afterPull.ahead ?? 0) > 0) {
await git.gitPush(currentDirectory);
}
await refreshStatusAndBranches(false);
await refreshRemotes();
} else {
await refreshStatusAndBranches(false);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t('gitView.toast.createCommitFailed'));
} finally {
setCommitAction(null);
if (options.pushAfter) setSyncAction(null);
}
};
const changeGroups = React.useMemo<ChangesGroupConfig[]>(() => {
const groups: ChangesGroupConfig[] = [];
if (stagedChangeEntries.length > 0) {
groups.push({
id: 'staged',
title: t('gitView.changes.stagedTitle'),
entries: stagedChangeEntries,
actionSymbol: '-',
actionAllLabel: t('gitView.changes.unstageAllAria'),
getActionLabel: (path: string) => t('gitView.changes.unstageFileAria', { path }),
onActionFile: (path: string) => void moveChangePaths([path], 'unstage'),
onActionAll: (paths: string[]) => void moveChangePaths(paths, 'unstage'),
onViewDiff: (path: string) => handleViewChangeDiff(path, true),
onRevertFile: handleRevertFile,
showRevertActions: false,
accent: true,
});
}
if (unstagedChangeEntries.length > 0) {
groups.push({
id: 'unstaged',
title: t('gitView.changes.title'),
entries: unstagedChangeEntries,
actionSymbol: '+',
actionAllLabel: t('gitView.changes.stageAllAria'),
getActionLabel: (path: string) => t('gitView.changes.stageFileAria', { path }),
onActionFile: (path: string) => void moveChangePaths([path], 'stage'),
onActionAll: (paths: string[]) => void moveChangePaths(paths, 'stage'),
onViewDiff: (path: string) => handleViewChangeDiff(path, false),
onRevertFile: handleRevertFile,
});
}
return groups;
}, [handleRevertFile, handleViewChangeDiff, moveChangePaths, stagedChangeEntries, t, unstagedChangeEntries]);
if (!currentDirectory) {
return <MobileChangesState message={t('gitView.empty.selectSessionOrDirectory')} />;
}
if (isLoadingStatus && isGitRepo === null) {
return <MobileChangesState loading message={t('gitView.loading.checkingRepository')} />;
}
if (isGitRepo === false) {
return <MobileChangesState icon message={t('gitView.empty.notGitRepository')} description={t('gitView.empty.notGitRepositoryDescription')} />;
}
if (route.type === 'diff') {
return (
<MobileDiffDetail
path={route.path}
diff={selectedDiff}
fileExists={Boolean(selectedFileEntry)}
error={diffLoadError}
onBack={() => setRoute({ type: 'list' })}
onRetry={() => setDiffRetryNonce((value) => value + 1)}
/>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3 text-foreground">
{onClose ? (
<button
type="button"
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.surface.closeAria')}
onClick={onClose}
style={{ touchAction: 'manipulation' }}
>
<RiCloseLine className="size-5" />
</button>
) : null}
<div className="min-w-0 flex-1 px-1">
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
<p className="truncate typography-micro text-muted-foreground">
{status?.current || currentDirectory}
</p>
</div>
<SyncActions
syncAction={syncAction}
remotes={effectiveRemotes}
onFetch={(remote) => void handleSyncAction('fetch', remote)}
onSync={(remote) => void handleSyncAction('sync', remote)}
disabled={commitAction !== null || isLoadingStatus}
aheadCount={status?.ahead ?? 0}
behindCount={status?.behind ?? 0}
trackingRemoteName={status?.tracking?.split('/')[0]}
hasUncommittedChanges={changeEntries.length > 0}
/>
</header>
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{changeEntries.length > 0 ? (
<div className="flex flex-col gap-4">
<ChangesPanel
groups={changeGroups}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onRevertAll={handleRevertAll}
isRevertingAll={isRevertingAll}
headerBackgroundClassName="bg-transparent"
onVisiblePathsChange={setVisibleChangePaths}
/>
<CommitSection
stagedCount={stagedChangeEntries.length}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => void handleCommit({ pushAfter: false })}
onCommitAndPush={() => void handleCommit({ pushAfter: true })}
commitAction={commitAction}
gitmojiEnabled={false}
onOpenGitmojiPicker={() => {}}
/>
</div>
) : (
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
)}
</ScrollShadow>
</div>
);
};
const MobileChangesState: React.FC<{
message: string;
description?: string;
loading?: boolean;
icon?: boolean;
}> = ({ message, description, loading = false, icon = false }) => (
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="flex max-w-sm flex-col items-center gap-2">
{loading ? <RiLoader4Line className="size-5 animate-spin text-muted-foreground" /> : null}
{icon ? <RiGitBranchLine className="size-6 text-muted-foreground" /> : null}
<p className="typography-ui-label font-semibold text-foreground">{message}</p>
{description ? <p className="typography-meta text-muted-foreground">{description}</p> : null}
</div>
</div>
);
const MobileDiffDetail: React.FC<{
path: string;
diff: { original: string; modified: string; isBinary?: boolean } | null;
fileExists: boolean;
error: string | null;
onBack: () => void;
onRetry: () => void;
}> = ({ path, diff, fileExists, error, onBack, onRetry }) => {
const { t } = useI18n();
const language = React.useMemo(() => getLanguageFromExtension(path) || 'text', [path]);
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/50 px-3 text-foreground">
<button
type="button"
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('header.actions.backAria')}
onClick={onBack}
>
<RiArrowLeftLine className="size-5" />
</button>
<div className="min-w-0 flex-1 px-2">
<h2 className="truncate typography-ui-header text-foreground">{path}</h2>
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden">
{!fileExists ? (
<MobileChangesState icon message={t('mobile.changes.diffDetail.missingTitle')} description={t('mobile.changes.diffDetail.missingDescription')} />
) : error ? (
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="flex max-w-sm flex-col items-center gap-3">
<p className="typography-ui-label font-semibold text-foreground">{t('mobile.changes.diffDetail.loadFailed')}</p>
<p className="typography-meta text-muted-foreground">{error}</p>
<Button type="button" size="sm" variant="outline" onClick={onRetry}>{t('diffView.actions.retry')}</Button>
</div>
</div>
) : !diff ? (
<MobileChangesState loading message={t('diffView.state.loadingDiff')} />
) : diff.isBinary ? (
<MobileChangesState icon message={t('diffView.binary.unavailable')} />
) : isImageFile(path) ? (
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
) : (
<ScrollShadow
className="h-full overflow-y-auto overflow-x-hidden p-3"
data-diff-virtual-root
data-diff-virtual-content
>
<PierreDiffViewer
original={diff.original}
modified={diff.modified}
language={language}
fileName={path}
renderSideBySide={false}
wrapLines={true}
layout="inline"
/>
</ScrollShadow>
)}
</div>
</div>
);
};
+534
View File
@@ -0,0 +1,534 @@
import React from 'react';
import { File as PierreFile } from '@pierre/diffs/react';
import {
RiArrowLeftLine,
RiArrowRightSLine,
RiClipboardLine,
RiCloseLine,
RiFileCopyLine,
RiFolder3Fill,
RiFolderOpenFill,
RiLoader4Line,
RiRefreshLine,
RiSearchLine,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { PIERRE_RUNTIME_BASE_CSS } from '@/components/views/PierreDiffViewer';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import { getImageMimeType, getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
import type { FileListEntry, FileSearchResult } from '@/lib/api/types';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
type MobileFilesRoute =
| { type: 'browser'; directory: string }
| { type: 'file'; path: string; returnDirectory: string };
const MAX_MOBILE_FILE_CHARS = 250_000;
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
const getNameFromPath = (path: string): string => {
const normalized = normalizePath(path);
if (!normalized || normalized === '/') return normalized || '/';
return normalized.split('/').filter(Boolean).at(-1) ?? normalized;
};
const getParentDirectory = (path: string): string | null => {
const normalized = normalizePath(path);
if (!normalized || normalized === '/') return null;
const index = normalized.lastIndexOf('/');
if (index <= 0) return normalized.startsWith('/') ? '/' : null;
return normalized.slice(0, index);
};
const getRelativePath = (path: string, root: string): string => {
const normalizedPath = normalizePath(path);
const normalizedRoot = normalizePath(root);
if (!normalizedRoot || normalizedPath === normalizedRoot) return getNameFromPath(normalizedPath);
if (normalizedPath.startsWith(`${normalizedRoot}/`)) return normalizedPath.slice(normalizedRoot.length + 1);
return normalizedPath;
};
const formatFileSize = (size?: number): string => {
if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) return '';
if (size < 1024) return `${size} B`;
const units = ['KB', 'MB', 'GB'];
let value = size / 1024;
for (const unit of units) {
if (value < 1024 || unit === units[units.length - 1]) return `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`;
value /= 1024;
}
return '';
};
const getImageSrc = (path: string): string => {
if (path.toLowerCase().endsWith('.svg')) {
return '';
}
return getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { path });
};
const isMarkdownFile = (path: string): boolean => /\.(md|mdx|markdown)$/i.test(path);
const isJsonFile = (path: string): boolean => /\.(json|jsonc)$/i.test(path);
type MobileFilesSurfaceProps = {
/** When provided, header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
onClose?: () => void;
};
export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose }) => {
const { t } = useI18n();
const { files } = useRuntimeAPIs();
const root = normalizePath(useEffectiveDirectory() ?? null);
const [route, setRoute] = React.useState<MobileFilesRoute>(() => ({ type: 'browser', directory: root }));
const [entries, setEntries] = React.useState<FileListEntry[]>([]);
const [isLoadingDirectory, setIsLoadingDirectory] = React.useState(false);
const [directoryError, setDirectoryError] = React.useState<string | null>(null);
const [query, setQuery] = React.useState('');
const [searchResults, setSearchResults] = React.useState<FileSearchResult[]>([]);
const [isSearching, setIsSearching] = React.useState(false);
const [fileContent, setFileContent] = React.useState('');
const [fileError, setFileError] = React.useState<string | null>(null);
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
const directoryLoadRequestIdRef = React.useRef(0);
React.useEffect(() => {
if (!root) return;
setRoute((current) => {
if (current.type === 'browser' && current.directory) return current;
return { type: 'browser', directory: root };
});
}, [root]);
const currentDirectory = route.type === 'browser' ? route.directory : route.returnDirectory;
const loadDirectory = React.useCallback(async (directory: string) => {
if (!directory) return;
const requestId = directoryLoadRequestIdRef.current + 1;
directoryLoadRequestIdRef.current = requestId;
setIsLoadingDirectory(true);
setDirectoryError(null);
try {
const result = await files.listDirectory(directory);
if (directoryLoadRequestIdRef.current !== requestId) return;
setEntries(result.entries.slice().sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
}));
} catch (error) {
if (directoryLoadRequestIdRef.current !== requestId) return;
setEntries([]);
setDirectoryError(error instanceof Error ? error.message : t('mobile.files.error.listFailed'));
} finally {
if (directoryLoadRequestIdRef.current === requestId) {
setIsLoadingDirectory(false);
}
}
}, [files, t]);
React.useEffect(() => {
if (route.type !== 'browser') return;
void loadDirectory(route.directory);
}, [loadDirectory, route]);
React.useEffect(() => {
if (route.type !== 'browser') return;
const normalizedQuery = query.trim();
if (!normalizedQuery) {
setSearchResults([]);
setIsSearching(false);
return;
}
let cancelled = false;
const timeoutId = window.setTimeout(() => {
setIsSearching(true);
void files.search({ directory: route.directory, query: normalizedQuery, maxResults: 40 })
.then((results) => {
if (!cancelled) setSearchResults(results);
})
.catch(() => {
if (!cancelled) setSearchResults([]);
})
.finally(() => {
if (!cancelled) setIsSearching(false);
});
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [files, query, route]);
React.useEffect(() => {
if (route.type !== 'file') return;
setFileContent('');
setFileError(null);
if (isImageFile(route.path) && !route.path.toLowerCase().endsWith('.svg')) {
setIsLoadingFile(false);
return;
}
if (!files.readFile) {
setFileError(t('mobile.files.error.readUnavailable'));
setIsLoadingFile(false);
return;
}
let cancelled = false;
setIsLoadingFile(true);
void files.readFile(route.path)
.then((result) => {
if (cancelled) return;
setFileContent(result.content.length > MAX_MOBILE_FILE_CHARS
? `${result.content.slice(0, MAX_MOBILE_FILE_CHARS)}\n\n${t('mobile.files.file.truncated')}`
: result.content);
})
.catch((error) => {
if (!cancelled) setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
})
.finally(() => {
if (!cancelled) setIsLoadingFile(false);
});
return () => {
cancelled = true;
};
}, [files, route, t]);
const openDirectory = (directory: string) => {
setQuery('');
setRoute({ type: 'browser', directory });
};
const openFile = (path: string) => {
setRoute({ type: 'file', path, returnDirectory: currentDirectory || root });
};
const handleCopyPath = async (path: string) => {
const result = await copyTextToClipboard(path);
if (result.ok) toast.success(t('mobile.files.toast.pathCopied'));
else toast.error(t('mobile.files.toast.copyFailed'));
};
const handleCopyContent = async () => {
const result = await copyTextToClipboard(fileContent);
if (result.ok) toast.success(t('mobile.files.toast.contentCopied'));
else toast.error(t('mobile.files.toast.copyFailed'));
};
if (!root) {
return <MobileFilesState message={t('mobile.files.empty.noDirectory')} />;
}
if (route.type === 'file') {
return (
<MobileFileDetail
path={route.path}
content={fileContent}
error={fileError}
isLoading={isLoadingFile}
onBack={() => setRoute({ type: 'browser', directory: route.returnDirectory })}
onCopyPath={() => void handleCopyPath(route.path)}
onCopyContent={() => void handleCopyContent()}
/>
);
}
const directoryLabel = route.directory === root ? t('mobile.files.rootDirectory') : getNameFromPath(route.directory);
const visibleSearchResults = query.trim() ? searchResults : [];
// Cap parent navigation at the project root: only allow stepping up while
// the parent stays inside (or equal to) the root.
const rawParent = getParentDirectory(route.directory);
const parentWithinRoot =
route.directory !== root && rawParent !== null && (rawParent === root || rawParent.startsWith(`${root}/`));
const canGoBack = parentWithinRoot && !query.trim();
const parentDirectory = parentWithinRoot ? rawParent : null;
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3 text-foreground">
{onClose ? (
<button
type="button"
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.surface.closeAria')}
onClick={onClose}
style={{ touchAction: 'manipulation' }}
>
<RiCloseLine className="size-5" />
</button>
) : null}
{canGoBack && parentDirectory ? (
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.files.backToParentAria', { name: getNameFromPath(parentDirectory) })}
onClick={() => openDirectory(parentDirectory)}
style={{ touchAction: 'manipulation' }}
>
<RiArrowLeftLine className="size-5" />
</button>
) : null}
<div className="min-w-0 flex-1 px-1">
<h2 className="truncate typography-ui-label text-foreground">{directoryLabel}</h2>
</div>
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.files.refreshAria')}
onClick={() => void loadDirectory(route.directory)}
style={{ touchAction: 'manipulation' }}
>
<RiRefreshLine className={cn('size-5', isLoadingDirectory && 'animate-spin')} />
</button>
</header>
<div className="shrink-0 px-4 pb-2 pt-1">
<div className="relative">
<RiSearchLine className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('mobile.files.search.placeholder')}
className="h-11 pl-9"
/>
</div>
</div>
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
{directoryError ? (
<MobileFilesState message={directoryError} />
) : query.trim() ? (
<MobileSearchResults results={visibleSearchResults} isSearching={isSearching} onOpenFile={openFile} />
) : (
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
{entries.length === 0 && !isLoadingDirectory ? (
<div className="px-4 py-8 text-center typography-body text-muted-foreground">{t('mobile.files.empty.directory')}</div>
) : null}
{entries.map((entry) => (
<MobileFileRow
key={entry.path}
name={entry.name}
path={entry.path}
directory={entry.isDirectory}
meta={entry.isDirectory ? undefined : formatFileSize(entry.size)}
onClick={() => entry.isDirectory ? openDirectory(entry.path) : openFile(entry.path)}
/>
))}
</div>
)}
</ScrollShadow>
</div>
);
};
const MobileFileRow: React.FC<{
name: string;
path: string;
directory: boolean;
meta?: string;
onClick: () => void;
}> = ({ name, path, directory, meta, onClick }) => (
<button
type="button"
className="flex min-h-14 w-full items-center gap-3 border-b border-border/30 px-3 py-2.5 text-left transition-colors last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
onClick={onClick}
style={{ touchAction: 'manipulation' }}
>
{directory ? (
<RiFolder3Fill className="size-5 shrink-0 text-primary/80" />
) : (
<FileTypeIcon filePath={path} className="size-5 shrink-0" />
)}
<span className="block min-w-0 flex-1 truncate typography-ui-label text-foreground">{name}</span>
{meta ? <span className="shrink-0 typography-micro text-muted-foreground">{meta}</span> : null}
{directory ? <RiArrowRightSLine className="size-4 shrink-0 text-muted-foreground/60" /> : null}
</button>
);
const MobileSearchResults: React.FC<{
results: FileSearchResult[];
isSearching: boolean;
onOpenFile: (path: string) => void;
}> = ({ results, isSearching, onOpenFile }) => {
const { t } = useI18n();
const root = normalizePath(useEffectiveDirectory() ?? null);
if (isSearching) return <MobileFilesState loading message={t('common.loading')} />;
if (results.length === 0) return <MobileFilesState message={t('mobile.files.search.empty')} />;
return (
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
{results.map((result) => (
<MobileFileRow
key={result.path}
name={getNameFromPath(result.path)}
path={result.path}
directory={false}
meta={getRelativePath(result.path, root)}
onClick={() => onOpenFile(result.path)}
/>
))}
</div>
);
};
const MobileFileDetail: React.FC<{
path: string;
content: string;
error: string | null;
isLoading: boolean;
onBack: () => void;
onCopyPath: () => void;
onCopyContent: () => void;
}> = ({ path, content, error, isLoading, onBack, onCopyPath, onCopyContent }) => {
const { t } = useI18n();
const imageAuthKey = isImageFile(path) && !path.toLowerCase().endsWith('.svg') ? path : '';
const [imageAuthReadyKey, setImageAuthReadyKey] = React.useState('');
React.useEffect(() => {
if (!imageAuthKey) {
setImageAuthReadyKey('');
return;
}
let cancelled = false;
setImageAuthReadyKey('');
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
.then((token) => {
if (!cancelled && token) setImageAuthReadyKey(imageAuthKey);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [imageAuthKey]);
const imageAuthLoading = Boolean(imageAuthKey && imageAuthReadyKey !== imageAuthKey);
const imageSrc = imageAuthLoading ? '' : getImageSrc(path);
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/50 px-3 text-foreground">
<button
type="button"
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('header.actions.backAria')}
onClick={onBack}
>
<RiArrowLeftLine className="size-5" />
</button>
<div className="min-w-0 flex-1">
<h2 className="truncate typography-ui-header text-foreground">{getNameFromPath(path)}</h2>
</div>
{!isImageFile(path) ? (
<Button type="button" variant="ghost" size="icon" onClick={onCopyContent} aria-label={t('mobile.files.copyContentAria')}>
<RiFileCopyLine className="size-4" />
</Button>
) : null}
<Button type="button" variant="ghost" size="icon" onClick={onCopyPath} aria-label={t('mobile.files.copyPathAria')}>
<RiClipboardLine className="size-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-hidden">
{isLoading || imageAuthLoading ? (
<MobileFilesState loading message={t('filesView.state.loading')} />
) : error ? (
<MobileFilesState message={error} />
) : isImageFile(path) && imageSrc ? (
<ScrollShadow className="h-full overflow-auto p-4">
<img src={imageSrc} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
</ScrollShadow>
) : isImageFile(path) ? (
<ScrollShadow className="h-full overflow-auto p-4">
<img src={`data:${getImageMimeType(path)};utf8,${encodeURIComponent(content)}`} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
</ScrollShadow>
) : (
<MobileTextFile path={path} content={content} />
)}
</div>
</div>
);
};
const MobileTextFile: React.FC<{ path: string; content: string }> = ({ path, content }) => {
const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem();
const lightTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
[availableThemes, lightThemeId],
);
const darkTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? getDefaultTheme(true),
[availableThemes, darkThemeId],
);
React.useEffect(() => {
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
}, [darkTheme, lightTheme]);
const pierreTheme = React.useMemo(
() => ({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id }),
[darkTheme.metadata.id, lightTheme.metadata.id],
);
if (isMarkdownFile(path)) {
return (
<ScrollShadow className="h-full overflow-y-auto px-4 py-4">
<SimpleMarkdownRenderer content={content} />
</ScrollShadow>
);
}
if (isJsonFile(path)) {
return <JsonTreeView jsonString={content} className="h-full overflow-auto" />;
}
return (
<div className="flex h-full flex-col overflow-hidden">
<ScrollShadow className="min-h-0 flex-1 overflow-auto bg-[var(--syntax-base-background)]">
<PierreFile
file={{
name: getNameFromPath(path),
contents: content,
lang: getLanguageFromExtension(path) || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreTheme,
themeType: currentTheme.metadata.variant === 'dark' ? 'dark' : 'light',
unsafeCSS: PIERRE_RUNTIME_BASE_CSS,
}}
className="block min-h-full w-full"
style={{ minHeight: '100%' }}
/>
</ScrollShadow>
</div>
);
};
const MobileFilesState: React.FC<{ message: string; loading?: boolean }> = ({ message, loading = false }) => (
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="flex max-w-sm flex-col items-center gap-2">
{loading ? <RiLoader4Line className="size-5 animate-spin text-muted-foreground" /> : <RiFolderOpenFill className="size-6 text-muted-foreground" />}
<p className="typography-ui-label font-semibold text-foreground">{message}</p>
</div>
</div>
);
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
const SURFACE_ROOT_ID = 'mobile-surface-root';
const DISMISS_THRESHOLD_PX = 90;
const ENTER_DELAY_MS = 16;
const ensureSurfaceRoot = (): HTMLElement | null => {
if (typeof document === 'undefined') return null;
let root = document.getElementById(SURFACE_ROOT_ID);
if (!root) {
root = document.createElement('div');
root.id = SURFACE_ROOT_ID;
document.body.appendChild(root);
}
return root;
};
export type MobileSurfaceShellProps = {
open: boolean;
onClose: () => void;
title?: React.ReactNode;
subtitle?: React.ReactNode;
trailing?: React.ReactNode;
/** When set, the leading icon becomes a back arrow that calls this. Otherwise it's a close X bound to onClose. */
onBack?: () => void;
/** If true, disable swipe-down-to-dismiss (e.g. when a nested view should keep gesture for itself). */
disableSwipeDismiss?: boolean;
/** If true, render only the drag handle and let the child render its own header. */
headerless?: boolean;
ariaLabel?: string;
children: React.ReactNode;
};
export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
open,
onClose,
title,
subtitle,
trailing,
onBack,
disableSwipeDismiss = false,
headerless = false,
ariaLabel,
children,
}) => {
const { t } = useI18n();
const rootRef = React.useRef<HTMLElement | null>(null);
const [mounted, setMounted] = React.useState(false);
const [entered, setEntered] = React.useState(false);
const [dragOffset, setDragOffset] = React.useState(0);
const dragStartYRef = React.useRef<number | null>(null);
const isDraggingRef = React.useRef(false);
const surfaceRef = React.useRef<HTMLElement | null>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
if (typeof document !== 'undefined' && !rootRef.current) {
rootRef.current = ensureSurfaceRoot();
}
React.useEffect(() => {
if (open) {
setMounted(true);
const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS);
return () => window.clearTimeout(id);
}
setEntered(false);
const id = window.setTimeout(() => setMounted(false), 220);
return () => window.clearTimeout(id);
}, [open]);
React.useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
document.body.style.overflow = 'hidden';
const focusFirstElement = () => {
const surface = surfaceRef.current;
if (!surface) return;
const focusable = surface.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
(focusable ?? surface).focus({ preventScroll: true });
};
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const surface = surfaceRef.current;
if (!surface) return;
const focusable = Array.from(surface.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)).filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true');
if (focusable.length === 0) {
event.preventDefault();
surface.focus({ preventScroll: true });
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus({ preventScroll: true });
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus({ preventScroll: true });
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
window.clearTimeout(focusTimer);
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
previousFocusRef.current?.focus?.({ preventScroll: true });
previousFocusRef.current = null;
};
}, [onClose, open]);
const handleDragStart = (event: React.TouchEvent<HTMLDivElement>) => {
if (disableSwipeDismiss) return;
dragStartYRef.current = event.touches[0]?.clientY ?? null;
isDraggingRef.current = true;
};
const handleDragMove = (event: React.TouchEvent<HTMLDivElement>) => {
if (!isDraggingRef.current || dragStartYRef.current == null) return;
const currentY = event.touches[0]?.clientY ?? dragStartYRef.current;
const delta = currentY - dragStartYRef.current;
setDragOffset(delta > 0 ? delta : 0);
};
const handleDragEnd = () => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
dragStartYRef.current = null;
if (dragOffset >= DISMISS_THRESHOLD_PX) {
setDragOffset(0);
onClose();
} else {
setDragOffset(0);
}
};
if (!mounted || !rootRef.current) return null;
const leading = onBack ? (
<button
type="button"
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('header.actions.backAria')}
onClick={onBack}
style={{ touchAction: 'manipulation' }}
>
<RiArrowLeftLine className="size-5" />
</button>
) : (
<button
type="button"
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.surface.closeAria')}
onClick={onClose}
style={{ touchAction: 'manipulation' }}
>
<RiCloseLine className="size-5" />
</button>
);
const visualTransform = entered
? `translateY(${dragOffset}px)`
: 'translateY(100%)';
return createPortal(
<div
className={cn(
'fixed inset-0 z-50 flex items-end',
'bg-[rgb(0_0_0_/_0.45)]',
'transition-opacity duration-200 ease-out',
entered ? 'opacity-100' : 'opacity-0',
)}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
>
<button
type="button"
className="absolute inset-0 cursor-default"
aria-label={t('mobile.surface.closeAria')}
onClick={onClose}
/>
<section
ref={surfaceRef}
className="relative flex h-[100dvh] w-full flex-col overflow-hidden rounded-t-[20px] border-t border-border/40 bg-background text-foreground shadow-[0_-12px_48px_rgb(0_0_0_/_0.35)] will-change-transform"
tabIndex={-1}
style={{
transform: visualTransform,
transition: isDraggingRef.current
? 'none'
: 'transform 220ms cubic-bezier(0.32, 0.72, 0, 1)',
paddingTop: 'var(--oc-safe-area-top, 0px)',
}}
>
<div
className="shrink-0 select-none"
onTouchStart={handleDragStart}
onTouchMove={handleDragMove}
onTouchEnd={handleDragEnd}
onTouchCancel={handleDragEnd}
>
<div className="flex items-center justify-center pt-2 pb-1">
<span className="h-1 w-10 rounded-full bg-[var(--surface-muted)]" aria-hidden />
</div>
{!headerless ? (
<header className="flex h-[var(--oc-header-height,56px)] items-center gap-2 px-3">
{leading}
<div className="min-w-0 flex-1 px-1">
{title ? (
typeof title === 'string' ? (
<h2 className="truncate typography-ui-label text-foreground">{title}</h2>
) : (
title
)
) : null}
{subtitle ? (
typeof subtitle === 'string' ? (
<p className="truncate typography-micro text-muted-foreground">{subtitle}</p>
) : (
subtitle
)
) : null}
</div>
{trailing ? <div className="flex shrink-0 items-center gap-1.5">{trailing}</div> : null}
</header>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-hidden" style={{ paddingBottom: 'var(--oc-safe-area-bottom, 0px)' }}>
{children}
</div>
</section>
</div>,
rootRef.current,
);
};
+2 -1
View File
@@ -13,6 +13,7 @@ import { useRouter } from '@/hooks/useRouter';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
import type { RuntimeAPIs } from '@/lib/api/types';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -70,7 +71,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
let cancelled = false;
const run = async () => {
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null);
if (!res || !res.ok || cancelled) return;
const data = (await res.json().catch(() => null)) as null | {
planModeExperimentalEnabled?: unknown;
+38
View File
@@ -0,0 +1,38 @@
/* eslint-disable react-refresh/only-export-components */
import React from 'react';
export type MobileAppActions = {
/** Open the Changes surface as a modal and (optionally) navigate it to a specific diff. */
openChanges: (options?: { diffPath?: string | null; staged?: boolean }) => void;
/** Open the Files surface as a modal. */
openFiles: () => void;
/** Open the Settings surface as a modal. */
openSettings: () => void;
};
const DedicatedMobileAppContext = React.createContext<MobileAppActions | null>(null);
export const DedicatedMobileAppProvider: React.FC<{
actions: MobileAppActions;
children: React.ReactNode;
}> = ({ actions, children }) => (
<DedicatedMobileAppContext.Provider value={actions}>{children}</DedicatedMobileAppContext.Provider>
);
/**
* Returns true when the surrounding tree is the dedicated MobileApp root
* (Capacitor or hosted /mobile.html), as opposed to the desktop responsive
* mobile path. Use this to suppress UI that exists only to bridge the
* desktop sidebar/layout into mobile, since the dedicated mobile root has
* its own native-feeling navigation and no sidebars to bridge into.
*/
export const useIsDedicatedMobileApp = (): boolean => React.useContext(DedicatedMobileAppContext) !== null;
/**
* Returns the dedicated mobile app's surface-opening actions, or null when
* not inside the dedicated mobile root. Components living in shared chat /
* input code can use this to route navigation to mobile-native surfaces
* (e.g. open the Changes diff for a file from PendingChangesBar) instead of
* desktop sidebars.
*/
export const useMobileAppActions = (): MobileAppActions | null => React.useContext(DedicatedMobileAppContext);
+61
View File
@@ -0,0 +1,61 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@/styles/fonts';
import '@/index.css';
import '@/lib/debug';
import { SessionAuthGate } from '@/components/auth/SessionAuthGate';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { ThemeProvider } from '@/components/providers/ThemeProvider';
import { ThemeSystemProvider } from '@/contexts/ThemeSystemContext';
import type { RuntimeAPIs } from '@/lib/api/types';
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
import { initializeLocale, I18nProvider } from '@/lib/i18n';
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startTypographyWatcher } from '@/lib/typographyWatcher';
import { MobileApp } from './MobileApp';
const initializeSharedPreferences = () => {
initializeLocale();
void initializeAppearancePreferences().then(() => {
void Promise.all([
syncDesktopSettings(),
applyPersistedDirectoryPreferences(),
]).catch((err) => {
console.error('[mobile-main] settings init failed:', err);
});
startAppearanceAutoSave();
startModelPrefsAutoSave();
startTypographyWatcher();
}).catch((err) => {
console.error('[mobile-main] appearance init failed:', err);
});
};
export function renderMobileApp(apis: RuntimeAPIs) {
initializeSharedPreferences();
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
createRoot(rootElement).render(
<StrictMode>
<I18nProvider>
<ThemeSystemProvider>
<ThemeProvider>
<DiffWorkerProvider>
<SessionAuthGate>
<MobileApp apis={apis} />
</SessionAuthGate>
</DiffWorkerProvider>
</ThemeProvider>
</ThemeSystemProvider>
</I18nProvider>
</StrictMode>,
);
}
@@ -11,6 +11,9 @@ import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitc
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
import {
authenticateWithPasskey,
cancelPasskeyCeremony,
@@ -23,9 +26,47 @@ import {
const STATUS_CHECK_ENDPOINT = '/auth/session';
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local';
const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local';
const readLocalOrigin = (): string => {
if (typeof window === 'undefined') return '';
const injected = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__;
return typeof injected === 'string' ? injected.trim() : '';
};
const sameOrigin = (left: string, right: string): boolean => {
const normalizedLeft = normalizeHostUrl(left);
const normalizedRight = normalizeHostUrl(right);
if (!normalizedLeft || !normalizedRight) return false;
try {
return new URL(normalizedLeft).origin === new URL(normalizedRight).origin;
} catch {
return false;
}
};
const shouldIssueDesktopClientToken = (): boolean => {
return isDesktopShell();
};
const isLocalDesktopRuntime = (): boolean => {
if (!isDesktopShell()) return false;
const apiBaseUrl = getRuntimeApiBaseUrl();
const localOrigin = readLocalOrigin();
return Boolean(localOrigin && sameOrigin(localOrigin, apiBaseUrl));
};
const desktopClientAuthMetadata = (): { clientKind?: string; dedupeKey?: string } => {
if (!isLocalDesktopRuntime()) return {};
return {
clientKind: LOCAL_DESKTOP_CLIENT_KIND,
dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY,
};
};
const fetchSessionStatus = async (): Promise<Response> => {
const response = await fetch(STATUS_CHECK_ENDPOINT, {
const response = await runtimeFetch(STATUS_CHECK_ENDPOINT, {
method: 'GET',
credentials: 'include',
headers: {
@@ -43,18 +84,106 @@ const readStoredTrustDevice = (): boolean => {
};
const submitPassword = async (password: string, trustDevice: boolean): Promise<Response> => {
const response = await fetch(STATUS_CHECK_ENDPOINT, {
const issueClientToken = shouldIssueDesktopClientToken();
const response = await runtimeFetch(STATUS_CHECK_ENDPOINT, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ password, trustDevice }),
body: JSON.stringify({
password,
trustDevice,
issueClientToken,
clientLabel: 'OpenChamber Desktop',
...desktopClientAuthMetadata(),
}),
});
return response;
};
const issueDesktopClientToken = async (): Promise<string> => {
if (!isDesktopShell()) {
return '';
}
const response = await runtimeFetch('/api/client-auth/clients', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ label: 'OpenChamber Desktop', ...desktopClientAuthMetadata() }),
}).catch(() => null);
if (!response?.ok) {
return '';
}
const payload = await response.json().catch(() => null) as { token?: unknown } | null;
return typeof payload?.token === 'string' ? payload.token.trim() : '';
};
const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise<string> => {
if (!isDesktopShell() || typeof window === 'undefined') {
return '';
}
const invoke = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__?.core?.invoke;
if (typeof invoke !== 'function') {
return '';
}
const response = await invoke('desktop_remote_password_login', {
url: getRuntimeApiBaseUrl(),
password,
trustDevice,
}).catch(() => null);
if (!response || typeof response !== 'object') {
return '';
}
const token = (response as { token?: unknown }).token;
return typeof token === 'string' ? token.trim() : '';
};
const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise<void> => {
if (!isDesktopShell() || !clientToken) return;
const cfg = await desktopHostsGet().catch(() => null);
if (!cfg) return;
if (cfg.localOrigin && sameOrigin(cfg.localOrigin, apiBaseUrl)) {
await desktopHostsSet({
hosts: cfg.hosts,
defaultHostId: cfg.defaultHostId,
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
localClientToken: clientToken,
}).catch(() => undefined);
return;
}
let changed = false;
const hosts = cfg.hosts.map((host) => {
if (!sameOrigin(getDesktopHostApiUrl(host), apiBaseUrl)) {
return host;
}
if (host.clientToken === clientToken) {
return host;
}
changed = true;
return { ...host, clientToken };
});
if (!changed) return;
await desktopHostsSet({
hosts,
defaultHostId: cfg.defaultHostId,
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
}).catch(() => undefined);
};
const applyDesktopClientToken = async (clientToken: string): Promise<void> => {
if (!clientToken) return;
const apiBaseUrl = getRuntimeApiBaseUrl();
await persistDesktopClientToken(apiBaseUrl, clientToken);
switchRuntimeEndpoint({ apiBaseUrl, clientToken, runtimeKey: getRuntimeKey() });
};
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const titlebarDragStyle = React.useMemo<React.CSSProperties>(() => {
return {
@@ -268,6 +397,21 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
void checkStatus();
}, [checkStatus, skipAuth]);
React.useEffect(() => {
if (skipAuth) {
return;
}
return subscribeRuntimeEndpointChanged(() => {
setPassword('');
setErrorMessage('');
setRetryAfter(undefined);
setIsTunnelLocked(false);
setState('pending');
void checkStatus();
});
}, [checkStatus, skipAuth]);
React.useEffect(() => {
if (!skipAuth && state === 'locked') {
hasResyncedRef.current = false;
@@ -336,8 +480,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
try {
const response = await submitPassword(password, trustDevice);
if (response.ok) {
const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null;
const shouldUseClientToken = shouldIssueDesktopClientToken();
const clientToken = shouldUseClientToken
? (typeof payload?.clientToken === 'string' && payload.clientToken.trim()
? payload.clientToken.trim()
: await issueDesktopClientTokenViaShell(password, trustDevice) || await issueDesktopClientToken())
: '';
setPassword('');
setIsTunnelLocked(false);
if (clientToken) {
await applyDesktopClientToken(clientToken);
}
if (enrollPasskey && supportsPasskeys) {
try {
await registerPasskeyForCurrentSession();
@@ -402,7 +556,17 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
setErrorMessage('');
try {
await authenticateWithPasskey(trustDevice);
const payload = await authenticateWithPasskey(trustDevice, {
issueClientToken: shouldIssueDesktopClientToken(),
clientLabel: 'OpenChamber Desktop',
...desktopClientAuthMetadata(),
}) as { clientToken?: unknown } | null;
const clientToken = shouldIssueDesktopClientToken() && typeof payload?.clientToken === 'string' && payload.clientToken.trim()
? payload.clientToken.trim()
: '';
if (clientToken) {
await applyDesktopClientToken(clientToken);
}
setPassword('');
setState('authenticated');
@@ -142,10 +142,8 @@ type ChatViewportProps = {
stickyUserHeader: boolean;
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
turnStart: number;
pendingRevealWork: boolean;
renderedMessages: SessionMessageRecord[];
hasMoreAboveTurns: boolean;
isLoadingOlder: boolean;
sessionIsWorking: boolean;
streamingMessageId: string | null;
@@ -158,7 +156,6 @@ type ChatViewportProps = {
} | null;
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
handleLoadOlder: () => void;
handleHistoryScroll: () => void;
scrollToBottom: () => void;
sessionQuestions: QuestionRequest[];
@@ -173,10 +170,8 @@ const ChatViewport = React.memo(({
stickyUserHeader,
scrollRef,
messageListRef,
turnStart,
pendingRevealWork,
renderedMessages,
hasMoreAboveTurns,
isLoadingOlder,
sessionIsWorking,
streamingMessageId,
@@ -184,7 +179,6 @@ const ChatViewport = React.memo(({
retryOverlay,
handleMessageContentChange,
getAnimationHandlers,
handleLoadOlder,
handleHistoryScroll,
scrollToBottom,
sessionQuestions,
@@ -230,7 +224,6 @@ const ChatViewport = React.memo(({
<MessageList
ref={messageListRef}
sessionKey={currentSessionId}
turnStart={turnStart}
disableStaging={pendingRevealWork}
messages={renderedMessages}
sessionIsWorking={sessionIsWorking}
@@ -239,9 +232,7 @@ const ChatViewport = React.memo(({
retryOverlay={retryOverlay}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
hasMoreAbove={hasMoreAboveTurns}
isLoadingOlder={isLoadingOlder}
onLoadOlder={handleLoadOlder}
scrollToBottom={scrollToBottom}
scrollRef={scrollRef}
/>
@@ -274,10 +265,8 @@ const ChatViewport = React.memo(({
&& prev.stickyUserHeader === next.stickyUserHeader
&& prev.scrollRef === next.scrollRef
&& prev.messageListRef === next.messageListRef
&& prev.turnStart === next.turnStart
&& prev.pendingRevealWork === next.pendingRevealWork
&& prev.renderedMessages === next.renderedMessages
&& prev.hasMoreAboveTurns === next.hasMoreAboveTurns
&& prev.isLoadingOlder === next.isLoadingOlder
&& prev.sessionIsWorking === next.sessionIsWorking
&& prev.streamingMessageId === next.streamingMessageId
@@ -285,7 +274,6 @@ const ChatViewport = React.memo(({
&& prev.retryOverlay === next.retryOverlay
&& prev.handleMessageContentChange === next.handleMessageContentChange
&& prev.getAnimationHandlers === next.getAnimationHandlers
&& prev.handleLoadOlder === next.handleLoadOlder
&& prev.handleHistoryScroll === next.handleHistoryScroll
&& prev.scrollToBottom === next.scrollToBottom
&& prev.sessionQuestions === next.sessionQuestions
@@ -645,8 +633,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
isPinned,
showScrollButton,
});
const { loadEarlier } = timelineController;
const resumeToLatestInstant = React.useCallback(() => {
goToBottom('instant');
}, [goToBottom]);
@@ -662,10 +648,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
handleMessageContentChange('permission');
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
const handleLoadOlder = React.useCallback(() => {
void loadEarlier({ userInitiated: true });
}, [loadEarlier]);
const navigation = useChatTurnNavigation({
sessionId: currentSessionId,
turnIds: timelineController.turnIds,
@@ -957,10 +939,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
stickyUserHeader={stickyUserHeader}
scrollRef={scrollRef}
messageListRef={messageListRef}
turnStart={timelineController.turnStart}
pendingRevealWork={timelineController.pendingRevealWork}
renderedMessages={timelineController.renderedMessages}
hasMoreAboveTurns={timelineController.historySignals.hasMoreAboveTurns}
isLoadingOlder={timelineController.isLoadingOlder}
sessionIsWorking={sessionIsWorking}
streamingMessageId={streamingMessageId}
@@ -968,7 +948,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
retryOverlay={retryOverlay}
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
handleLoadOlder={handleLoadOlder}
handleHistoryScroll={timelineController.handleHistoryScroll}
scrollToBottom={resumeToLatestInstant}
sessionQuestions={sessionQuestions}
+44 -31
View File
@@ -31,12 +31,13 @@ import { PendingChangesBar } from './PendingChangesBar';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
import { MobileSessionStatusBar, MobileSessionPanelTrigger } from './MobileSessionStatusBar';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
// useMessageStore removed — messages now come from sync system
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isIMECompositionEvent } from '@/lib/ime';
import { StopIcon } from '@/components/icons/StopIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -56,7 +57,7 @@ import { DraftPresetChips } from './DraftPresetChips';
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
import { opencodeClient } from '@/lib/opencode/client';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
@@ -1030,11 +1031,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
const { git: runtimeGit, vscode: vscodeApi } = useRuntimeAPIs();
const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent);
const cycleAgentShortcut = React.useMemo(() => (
getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined)
), [cycleAgentShortcutOverride]);
const { git: runtimeGit } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
const chatSearchDirectory = useChatSearchDirectory();
const isGitRepo = useIsGitRepo(currentDirectory);
@@ -1869,14 +1870,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
else if (commandName === 'compact' && currentSessionId) {
try {
await sessionActions.waitForConnectionOrThrow();
const { opencodeClient } = await import('@/lib/opencode/client');
const sdk = opencodeClient.getSdkClient();
const configState = useConfigStore.getState();
await sdk.session.summarize({
sessionID: currentSessionId,
modelID: configState.currentModelId || '',
providerID: configState.currentProviderId || '',
});
const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined;
await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory);
} catch (error) {
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed'));
}
@@ -2722,7 +2717,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} else {
setShowFileMention(false);
}
}, [inputMode, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
}, [
inputMode,
setCommandQuery,
setMentionQuery,
setShowCommandAutocomplete,
setShowFileMention,
setShowSkillAutocomplete,
setShowSnippetAutocomplete,
setSkillQuery,
setSnippetQuery,
]);
const insertTextAtSelection = React.useCallback((text: string) => {
if (!text) {
@@ -3469,7 +3474,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const blob = new Blob([byteArray], { type: result.mime || 'application/octet-stream' });
file = new File([blob], fileName, { type: result.mime || 'application/octet-stream' });
} else {
const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`);
const response = await runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } });
if (!response.ok) {
throw new Error(`Failed to read dropped file (${response.status})`);
}
@@ -3523,8 +3528,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const handleVSCodePickFiles = React.useCallback(async () => {
try {
const response = await fetch('/api/vscode/pick-files');
const data = await response.json();
const data = (await vscodeApi?.pickFiles?.()) as {
files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>;
skipped?: Array<{ name?: string; reason?: string }>;
} | undefined;
const picked = Array.isArray(data?.files) ? data.files : [];
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
@@ -3563,7 +3570,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed'));
}
}, [attachFiles, t]);
}, [attachFiles, t, vscodeApi]);
const handlePickLocalFiles = React.useCallback(() => {
if (isVSCodeRuntime()) {
@@ -3823,30 +3830,32 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
iconBackground?: string | null;
}) => {
const imageUrl = getProjectIconImageUrl(
{ id: project.id, iconImage: project.iconImage ?? null },
{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
},
);
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
);
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{imageUrl ? (
{project.iconImage ? (
<span
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
<ProjectIconImage
project={{ id: project.id, iconImage: project.iconImage ?? null }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
)}
) : fallbackIcon}
<span className="truncate">{getProjectDisplayLabel(project)}</span>
</span>
);
@@ -4426,6 +4435,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<>
<div className="flex w-full items-center justify-between gap-x-1.5">
<div className="flex items-center gap-x-1.5">
<MobileSessionPanelTrigger
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
@@ -4530,7 +4543,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
)}
</div>
{/* Mobile Session Status Bar - above input */}
{/* Mobile session panel: slide-up overlay toggled by MobileSessionPanelTrigger. */}
{isMobile && <MobileSessionStatusBar />}
</div>
</div>
@@ -6,7 +6,7 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
@@ -19,7 +19,8 @@ export const FileAttachmentButton = memo(() => {
const fileInputRef = useRef<HTMLInputElement>(null);
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
const isMobile = useUIStore((state) => state.isMobile);
const isVSCodeRuntime = useIsVSCodeRuntime();
const runtimeApis = useRuntimeAPIs();
const isVSCodeRuntime = runtimeApis.runtime.isVSCode;
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
@@ -47,8 +48,10 @@ export const FileAttachmentButton = memo(() => {
const handleVSCodePick = async () => {
try {
const response = await fetch('/api/vscode/pick-files');
const data = await response.json();
const data = (await runtimeApis.vscode?.pickFiles?.()) as {
files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>;
skipped?: Array<{ name?: string; reason?: string }>;
} | undefined;
const picked = Array.isArray(data?.files) ? data.files : [];
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
@@ -449,7 +452,7 @@ export const ActiveEditorFileSuggestion = memo(() => {
const attachedFiles = useInputStore((s) => s.attachedFiles)
const addVSCodeFileAttachment = useInputStore((s) => s.addVSCodeFileAttachment)
const addVSCodeSelectionAttachment = useInputStore((s) => s.addVSCodeSelectionAttachment)
const isVSCodeRuntime = useIsVSCodeRuntime();
const isVSCodeRuntime = useRuntimeAPIs().runtime.isVSCode;
if (!isVSCodeRuntime || !activeEditorFile) return null;
@@ -16,6 +16,7 @@ import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
@@ -1341,7 +1342,7 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void fetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}`, {
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}`, {
method: 'GET',
cache: 'no-store',
})
@@ -391,7 +391,6 @@ const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageE
interface MessageListProps {
sessionKey: string;
turnStart: number;
disableStaging?: boolean;
messages: ChatMessageEntry[];
sessionIsWorking?: boolean;
@@ -405,9 +404,7 @@ interface MessageListProps {
} | null;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
hasMoreAbove: boolean;
isLoadingOlder: boolean;
onLoadOlder: () => void;
scrollToBottom?: () => void;
scrollRef?: React.RefObject<HTMLDivElement | null>;
}
@@ -1101,7 +1098,6 @@ StreamingTailContent.displayName = 'StreamingTailContent';
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
sessionKey,
turnStart,
disableStaging = false,
messages,
sessionIsWorking = false,
@@ -1110,9 +1106,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
retryOverlay = null,
onMessageContentChange,
getAnimationHandlers,
hasMoreAbove,
isLoadingOlder,
onLoadOlder,
scrollToBottom,
scrollRef,
}, ref) => {
@@ -1128,7 +1122,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
animatedIds: Set<string>;
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
const stableOnLoadOlder = useStableEvent(onLoadOlder);
const stableScrollToBottom = useStableEvent(() => {
scrollToBottom?.();
});
@@ -1675,24 +1668,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return (
<div>
{(turnStart > 0 || hasMoreAbove) && (
<div className="flex justify-center py-3">
{isLoadingOlder ? (
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
Loading
</span>
) : (
<button
type="button"
onClick={stableOnLoadOlder}
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
>
Load older messages
</button>
)}
</div>
)}
<FadeInDisabledProvider disabled={disableFadeIn}>
<div className="relative w-full">
<StaticHistoryList
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useUIStore } from '@/stores/useUIStore';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { useMobileAppActions } from '@/apps/mobileAppContext';
import { sessionEvents } from '@/lib/sessionEvents';
import { normalizePath } from '@/components/session/sidebar/utils';
import { Icon } from "@/components/icon/Icon";
@@ -29,6 +30,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
);
const ensureStatus = useGitStore((s) => s.ensureStatus);
const fetchStatus = useGitStore((s) => s.fetchStatus);
const mobileActions = useMobileAppActions();
// Close popover when clicking outside
React.useEffect(() => {
@@ -90,6 +92,16 @@ export const PendingChangesBar: React.FC = React.memo(() => {
? file.path
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
// Dedicated mobile root: open the per-file diff inside the mobile Changes surface.
if (mobileActions) {
mobileActions.openChanges({
diffPath: file.relativePath,
staged: file.hasStagedChanges && !file.hasWorkingChanges,
});
setIsExpanded(false);
return;
}
const editor = runtime?.editor;
if (editor) {
void editor.openFile(absolutePath);
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test';
import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController';
const baseInput = {
sessionId: 'ses_1',
isPinned: true,
canLoadEarlier: true,
isLoadingOlder: false,
pendingRevealWork: false,
scrollHeight: 799,
clientHeight: 800,
};
describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => {
test('loads when pinned content does not fill the viewport', () => {
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport(baseInput)).toBe(true);
});
test('does not load when content already overflows', () => {
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport({
...baseInput,
scrollHeight: 802,
})).toBe(false);
});
test('does not load while user is away from bottom or history work is active', () => {
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport({
...baseInput,
isPinned: false,
})).toBe(false);
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport({
...baseInput,
isLoadingOlder: true,
})).toBe(false);
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport({
...baseInput,
pendingRevealWork: true,
})).toBe(false);
});
});
@@ -97,6 +97,21 @@ const rememberTurnModel = (key: string, value: { messages: ChatMessageEntry[]; m
turnModelCache.set(key, value)
}
export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: {
sessionId: string | null;
isPinned: boolean;
canLoadEarlier: boolean;
isLoadingOlder: boolean;
pendingRevealWork: boolean;
scrollHeight: number;
clientHeight: number;
}): boolean => {
if (!input.sessionId) return false;
if (!input.isPinned || !input.canLoadEarlier) return false;
if (input.isLoadingOlder || input.pendingRevealWork) return false;
return input.scrollHeight <= input.clientHeight + 1;
};
export const useChatTimelineController = ({
sessionId,
messages,
@@ -524,26 +539,32 @@ export const useChatTimelineController = ({
void loadEarlier({ userInitiated: true });
}, [loadEarlier, scrollRef]);
const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => {
if (historyInteractionRef.current) return;
const container = scrollRef.current;
if (!container) return;
if (!shouldAutoLoadEarlierForUnderfilledPinnedViewport({
sessionId: sessionIdRef.current,
isPinned: isPinnedRef.current,
canLoadEarlier: historySignalsRef.current.canLoadEarlier,
isLoadingOlder: isLoadingOlderRef.current,
pendingRevealWork: pendingRevealWorkRef.current,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
})) {
return;
}
void loadEarlier();
}, [loadEarlier, scrollRef]);
React.useEffect(() => {
if (!sessionId || isLoadingOlder || pendingRevealWork) {
return;
}
if (!isPinned || !historySignals.canLoadEarlier) {
return;
}
if (typeof window === 'undefined') {
return;
}
const frame = window.requestAnimationFrame(() => {
const container = scrollRef.current;
if (!container) return;
if (!isPinnedRef.current) return;
if (!historySignalsRef.current.canLoadEarlier) return;
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
if (container.scrollHeight > container.clientHeight + 1) return;
void loadEarlier();
loadEarlierIfPinnedViewportUnderfilled();
});
return () => window.cancelAnimationFrame(frame);
@@ -551,13 +572,49 @@ export const useChatTimelineController = ({
historySignals.canLoadEarlier,
isLoadingOlder,
isPinned,
loadEarlier,
loadEarlierIfPinnedViewportUnderfilled,
pendingRevealWork,
renderedMessages.length,
scrollRef,
sessionId,
]);
React.useEffect(() => {
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
return;
}
const container = scrollRef.current;
if (!container) {
return;
}
let frame: number | null = null;
const scheduleCheck = () => {
if (frame !== null) {
return;
}
frame = window.requestAnimationFrame(() => {
frame = null;
loadEarlierIfPinnedViewportUnderfilled();
});
};
const observer = new ResizeObserver(scheduleCheck);
observer.observe(container);
const content = container.firstElementChild;
if (content instanceof Element) {
observer.observe(content);
}
scheduleCheck();
return () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
}
observer.disconnect();
};
}, [loadEarlierIfPinnedViewportUnderfilled, scrollRef, sessionId]);
const scrollToTurn = React.useCallback(async (
turnId: string,
options?: { behavior?: ScrollBehavior },
@@ -31,6 +31,7 @@ import { TextSelectionMenu } from './TextSelectionMenu';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useChatSurfaceMode } from '@/components/chat/useChatSurfaceMode';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { toPng } from 'html-to-image';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
@@ -1034,6 +1035,7 @@ const AssistantMessageBody = React.memo(({
const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks);
const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks);
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
const vscodeApi = useRuntimeAPIs().vscode;
const isSortedRenderMode = chatRenderMode === 'sorted';
const collapsedPreviewCount = 7;
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
@@ -1319,17 +1321,10 @@ const AssistantMessageBody = React.memo(({
const fileName = `message-${messageId}.png`;
if (isVSCodeRuntime()) {
const response = await fetch('/api/vscode/save-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, dataUrl }),
});
if (!response.ok) {
const payload = await vscodeApi?.saveImage?.({ fileName, dataUrl }) as { saved?: boolean; canceled?: boolean; error?: string } | undefined;
if (!payload) {
throw new Error('Failed to save image in VS Code');
}
const payload = await response.json() as { saved?: boolean; canceled?: boolean; error?: string };
if (payload.saved !== true) {
if (payload.canceled) {
return;
@@ -1355,7 +1350,7 @@ const AssistantMessageBody = React.memo(({
}
}
},
[messageId, t]
[messageId, t, vscodeApi]
);
const activityPartsForTurn = React.useMemo(() => {
@@ -27,6 +27,7 @@ import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBloc
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -739,7 +740,7 @@ const MermaidPreviewDialog: React.FC<{
if (!normalizedPath) {
sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.'));
} else {
sourcePromise = fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`)
sourcePromise = runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } })
.then((response) => {
if (!response.ok) {
return Promise.reject(new Error(`Failed to read diagram file (${response.status})`));
@@ -9,29 +9,27 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { isElectronShell, isTauriShell, isDesktopShell } from '@/lib/desktop';
import { Icon } from "@/components/icon/Icon";
import { isTauriShell, isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import {
desktopHostProbe,
desktopHostsGet,
desktopHostsSet,
desktopLocalClientTokenGet,
desktopOpenNewWindowAtUrl,
getDesktopHostApiUrl,
locationMatchesHost,
normalizeHostUrl,
redactSensitiveUrl,
resolveDesktopHostUrl,
type DesktopHost,
type HostProbeResult,
} from '@/lib/desktopHosts';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import {
desktopSshConnect,
desktopSshDisconnect,
@@ -44,11 +42,18 @@ const LOCAL_HOST_ID = 'local';
const SSH_CONNECT_TIMEOUT_MS = 90_000;
const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
const runtimeKeyForHost = (host: DesktopHost): string => {
if (host.id === LOCAL_HOST_ID) return 'local';
return `host:${host.id}`;
};
type HostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
};
type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null;
const toNavigationUrl = (rawUrl: string): string => {
const normalized = normalizeHostUrl(rawUrl);
if (!normalized) {
@@ -71,37 +76,55 @@ const getLocalOrigin = (): string => {
return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
};
const makeId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const getLocalClientToken = async (): Promise<string> => {
if (!isElectronShell()) return '';
return desktopLocalClientTokenGet().catch(() => '');
};
const statusDotClass = (status: HostProbeResult['status'] | null): string => {
const statusDotClass = (status: HostDisplayStatus): string => {
if (status === 'ok') return 'bg-status-success';
if (status === 'auth') return 'bg-status-warning';
if (status === 'update-recommended') return 'bg-status-warning';
if (status === 'incompatible') return 'bg-status-error';
if (status === 'wrong-service') return 'bg-status-error';
if (status === 'unreachable') return 'bg-status-error';
if (status === 'checking') return 'bg-status-info';
return 'bg-muted-foreground/40';
};
const statusLabelKey = (status: HostProbeResult['status'] | null):
const isBlockedHostStatus = (status: HostProbeResult['status'] | null): boolean => {
return status === 'unreachable' || status === 'wrong-service' || status === 'incompatible';
};
const isBlockedDisplayStatus = (status: HostDisplayStatus): boolean => {
return status === 'unreachable' || status === 'wrong-service' || status === 'incompatible';
};
const statusLabelKey = (status: HostDisplayStatus):
| 'desktopHostSwitcher.status.connected'
| 'desktopHostSwitcher.status.authRequired'
| 'desktopHostSwitcher.status.checking'
| 'desktopHostSwitcher.status.updateRecommended'
| 'desktopHostSwitcher.status.incompatible'
| 'desktopHostSwitcher.status.wrongService'
| 'desktopHostSwitcher.status.unreachable'
| 'desktopHostSwitcher.status.unknown' => {
if (status === 'ok') return 'desktopHostSwitcher.status.connected';
if (status === 'auth') return 'desktopHostSwitcher.status.authRequired';
if (status === 'checking') return 'desktopHostSwitcher.status.checking';
if (status === 'update-recommended') return 'desktopHostSwitcher.status.updateRecommended';
if (status === 'incompatible') return 'desktopHostSwitcher.status.incompatible';
if (status === 'wrong-service') return 'desktopHostSwitcher.status.wrongService';
if (status === 'unreachable') return 'desktopHostSwitcher.status.unreachable';
return 'desktopHostSwitcher.status.unknown';
};
const statusIcon = (status: HostProbeResult['status'] | null) => {
const statusIcon = (status: HostDisplayStatus) => {
if (status === 'checking') return <Icon name="loader-4" className="h-4 w-4 animate-spin" />;
if (status === 'ok') return <Icon name="check" className="h-4 w-4" />;
if (status === 'auth') return <Icon name="shield-keyhole" className="h-4 w-4" />;
if (status === 'update-recommended') return <Icon name="shield-keyhole" className="h-4 w-4" />;
if (status === 'incompatible') return <Icon name="cloud-off" className="h-4 w-4" />;
if (status === 'wrong-service') return <Icon name="cloud-off" className="h-4 w-4" />;
if (status === 'unreachable') return <Icon name="cloud-off" className="h-4 w-4" />;
return <Icon name="earth" className="h-4 w-4" />;
@@ -204,18 +227,35 @@ const waitForSshReady = async (
throw new Error('Timed out waiting for SSH connection');
};
const buildLocalHost = (): DesktopHost => ({
const buildLocalHost = (localOrigin?: string | null): DesktopHost => ({
id: LOCAL_HOST_ID,
label: 'Local',
url: getLocalOrigin(),
url: localOrigin || getLocalOrigin(),
});
const resolveCurrentHost = (hosts: DesktopHost[]) => {
const currentHref = typeof window === 'undefined' ? '' : window.location.href;
const localOrigin = getLocalOrigin();
const localOrigin = hosts.find((host) => host.id === LOCAL_HOST_ID)?.url || getLocalOrigin();
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
const normalizedCurrent = normalizeHostUrl(currentHref) || currentHref;
if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) {
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
}
const runtimeMatch = hosts.find((h) => {
return runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(h)) : false;
});
if (runtimeMatch) {
return {
id: runtimeMatch.id,
label: runtimeMatch.label,
url: normalizeHostUrl(getDesktopHostApiUrl(runtimeMatch)) || getDesktopHostApiUrl(runtimeMatch),
};
}
if (currentHref && locationMatchesHost(currentHref, localOrigin)) {
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
}
@@ -228,6 +268,10 @@ const resolveCurrentHost = (hosts: DesktopHost[]) => {
return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url };
}
if (currentHref.startsWith('openchamber-ui://')) {
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
}
return {
id: 'custom',
label: redactSensitiveUrl(normalizedCurrent || 'Instance'),
@@ -255,6 +299,7 @@ export function DesktopHostSwitcherDialog({
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
const [probingHostIds, setProbingHostIds] = React.useState<Record<string, true>>({});
const [isLoading, setIsLoading] = React.useState(false);
const [isProbing, setIsProbing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
@@ -277,26 +322,32 @@ export function DesktopHostSwitcherDialog({
error: null,
});
const [error, setError] = React.useState<string>('');
const [localOrigin, setLocalOrigin] = React.useState<string>(() => getLocalOrigin());
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editLabel, setEditLabel] = React.useState('');
const [editUrl, setEditUrl] = React.useState('');
const [newLabel, setNewLabel] = React.useState('');
const [newUrl, setNewUrl] = React.useState('');
const [isAddFormOpen, setIsAddFormOpen] = React.useState(!embedded);
const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0);
const sshSwitchTokenRef = React.useRef(0);
const allHosts = React.useMemo(() => {
const local = buildLocalHost();
const local = buildLocalHost(localOrigin);
const normalizedRemote = configHosts.map((h) => ({
...h,
url: normalizeHostUrl(h.url) || h.url,
}));
return [local, ...normalizedRemote];
}, [configHosts]);
}, [configHosts, localOrigin]);
const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]);
React.useEffect(() => {
return subscribeRuntimeEndpointChanged(() => setRuntimeEndpointEpoch((epoch) => epoch + 1));
}, []);
const current = React.useMemo(() => {
void runtimeEndpointEpoch;
return resolveCurrentHost(allHosts);
}, [allHosts, runtimeEndpointEpoch]);
const currentDefaultLabel = React.useMemo(() => {
const id = defaultHostId || LOCAL_HOST_ID;
return allHosts.find((h) => h.id === id)?.label || t('desktopHostSwitcher.instance.local');
@@ -334,6 +385,9 @@ export function DesktopHostSwitcherDialog({
desktopSshInstancesGet().catch(() => ({ instances: [] })),
getSshStatusById(),
]);
if (cfg.localOrigin) {
setLocalOrigin(cfg.localOrigin);
}
const nextSshHostIds: Record<string, true> = {};
for (const instance of sshCfg.instances) {
nextSshHostIds[instance.id] = true;
@@ -356,14 +410,21 @@ export function DesktopHostSwitcherDialog({
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isTauriShell()) return;
setIsProbing(true);
const nextProbingHostIds: Record<string, true> = {};
for (const host of hosts) {
nextProbingHostIds[host.id] = true;
}
setProbingHostIds(nextProbingHostIds);
try {
const localClientToken = await getLocalClientToken();
const results = await Promise.all(
hosts.map(async (h) => {
const url = normalizeHostUrl(h.url);
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
if (!url) {
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const res = await desktopHostProbe(url).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
const res = await desktopHostProbe(url, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
@@ -373,6 +434,7 @@ export function DesktopHostSwitcherDialog({
}
setStatusById(next);
} finally {
setProbingHostIds({});
setIsProbing(false);
}
}, []);
@@ -382,16 +444,13 @@ export function DesktopHostSwitcherDialog({
setEditingId(null);
setEditLabel('');
setEditUrl('');
setNewLabel('');
setNewUrl('');
setIsAddFormOpen(!embedded);
setSwitchingHostId(null);
setSshSwitchModal({ open: false, hostId: null, hostLabel: '', phase: 'idle', detail: null, error: null });
setError('');
return;
}
void refresh();
}, [embedded, open, refresh]);
}, [open, refresh]);
React.useEffect(() => {
if (!open) return;
@@ -425,9 +484,32 @@ export function DesktopHostSwitcherDialog({
}, [open]);
const handleSwitch = React.useCallback(async (host: DesktopHost) => {
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
const origin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(host.url) || '');
const apiOrigin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(getDesktopHostApiUrl(host)) || '');
if (!origin) return;
if (isElectronShell()) {
if (!apiOrigin) return;
setSwitchingHostId(host.id);
const clientToken = host.id === LOCAL_HOST_ID ? await getLocalClientToken() : (host.clientToken || '');
const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
if (isBlockedHostStatus(probe.status)) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
setSwitchingHostId(null);
return;
}
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, runtimeKey: runtimeKeyForHost(host) });
onHostSwitched?.();
setSwitchingHostId(null);
return;
}
const isSshHost = Boolean(sshHostIds[host.id]);
if (host.id !== LOCAL_HOST_ID && isSshHost && isTauriShell()) {
@@ -516,13 +598,13 @@ export function DesktopHostSwitcherDialog({
if (host.id !== LOCAL_HOST_ID && isTauriShell()) {
setSwitchingHostId(host.id);
const probe = await desktopHostProbe(origin).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
if (probe.status === 'unreachable' || probe.status === 'wrong-service') {
if (isBlockedHostStatus(probe.status)) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
setSwitchingHostId(null);
return;
@@ -537,14 +619,7 @@ export function DesktopHostSwitcherDialog({
} catch {
window.location.href = target;
}
}, [onHostSwitched, sshHostIds, sshStatusesById, t]);
const beginEdit = React.useCallback((host: DesktopHost) => {
setEditingId(host.id);
setEditLabel(host.label);
setEditUrl(host.url);
setError('');
}, []);
}, [localOrigin, onHostSwitched, sshHostIds, sshStatusesById, t]);
const cancelEdit = React.useCallback(() => {
setEditingId(null);
@@ -563,60 +638,39 @@ export function DesktopHostSwitcherDialog({
return;
}
const url = normalizeHostUrl(editUrl);
if (!url) {
const resolved = resolveDesktopHostUrl(editUrl);
if (!resolved) {
setError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const url = resolved.persistedUrl;
const label = (editLabel || redactSensitiveUrl(url)).trim();
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
await persist(nextHosts, defaultHostId);
cancelEdit();
if (resolved.redeemUrl) {
window.location.assign(resolved.redeemUrl);
}
}, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist, t]);
const addHost = React.useCallback(async () => {
const url = normalizeHostUrl(newUrl);
if (!url) {
setError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const label = (newLabel || redactSensitiveUrl(url)).trim();
const id = makeId();
const nextHosts = [{ id, label, url }, ...configHosts];
await persist(nextHosts, defaultHostId);
setNewLabel('');
setNewUrl('');
if (embedded) {
setIsAddFormOpen(false);
}
}, [configHosts, defaultHostId, embedded, newLabel, newUrl, persist, t]);
const deleteHost = React.useCallback(async (id: string) => {
if (id === LOCAL_HOST_ID) return;
const nextHosts = configHosts.filter((h) => h.id !== id);
const nextDefault = defaultHostId === id ? LOCAL_HOST_ID : defaultHostId;
await persist(nextHosts, nextDefault);
}, [configHosts, defaultHostId, persist]);
const setDefault = React.useCallback(async (id: string) => {
const next = id === LOCAL_HOST_ID ? LOCAL_HOST_ID : id;
await persist(configHosts, next);
}, [configHosts, persist]);
const openInNewWindow = React.useCallback((host: DesktopHost) => {
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host);
if (!origin) return;
const target = toNavigationUrl(origin);
desktopOpenNewWindowAtUrl(target).catch((err: unknown) => {
desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null }).catch((err: unknown) => {
toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), {
description: err instanceof Error ? err.message : String(err),
});
});
}, [t]);
}, [localOrigin, t]);
const switchToLocal = React.useCallback(() => {
const switchToLocal = React.useCallback(async () => {
sshSwitchTokenRef.current += 1;
setSwitchingHostId(null);
setSshSwitchModal((prev) => ({
@@ -627,10 +681,16 @@ export function DesktopHostSwitcherDialog({
detail: null,
phase: 'idle',
}));
const localTarget = toNavigationUrl(getLocalOrigin());
const localTarget = toNavigationUrl(localOrigin);
if (isElectronShell()) {
const clientToken = await getLocalClientToken();
switchRuntimeEndpoint({ apiBaseUrl: localOrigin, clientToken: clientToken || null, runtimeKey: 'local' });
onHostSwitched?.();
return;
}
onHostSwitched?.();
window.location.assign(localTarget);
}, [onHostSwitched]);
}, [localOrigin, onHostSwitched]);
const cancelSshSwitch = React.useCallback(async () => {
const hostId = sshSwitchModal.hostId || switchingHostId;
@@ -754,16 +814,6 @@ export function DesktopHostSwitcherDialog({
</div>
)}
{tauriAvailable && (
<div className="flex-shrink-0 flex items-center justify-between gap-2 px-2.5 py-1.5">
<span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.ssh.needInstancesHint')}</span>
<Button type="button" variant="ghost" size="sm" onClick={openRemoteInstancesSettings}>
<Icon name="settings-3" className="h-4 w-4" />
{t('desktopHostSwitcher.actions.remoteSsh')}
</Button>
</div>
)}
{!tauriAvailable && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="typography-meta text-muted-foreground">
@@ -784,9 +834,10 @@ export function DesktopHostSwitcherDialog({
const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id;
const status = statusById[host.id] || null;
const sshStatus = sshStatusesById[host.id] || null;
const statusKind = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) : (status?.status ?? null);
const isChecking = !isSsh && Boolean(probingHostIds[host.id]);
const statusKind: HostDisplayStatus = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) : (isChecking ? 'checking' : (status?.status ?? null));
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url);
const displayLabel = host.id === LOCAL_HOST_ID
? t('desktopHostSwitcher.instance.local')
: redactSensitiveUrl(host.label);
@@ -811,24 +862,26 @@ export function DesktopHostSwitcherDialog({
aria-label={t('desktopHostSwitcher.actions.switchToAria', { instance: displayLabel })}
>
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(statusKind))} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className={cn('typography-ui-label truncate', isActive ? 'text-foreground' : 'text-foreground')}>
{displayLabel}
</span>
{isSsh && (
<span className="typography-micro px-1 rounded leading-none pb-px text-[var(--status-info)] bg-[var(--status-info)]/10">
SSH
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 max-w-[45%] items-center gap-1.5">
<span className="typography-ui-label truncate text-foreground">
{displayLabel}
</span>
)}
{isActive && (
<span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.header.current')}</span>
)}
<span className="inline-flex items-center gap-1 typography-micro text-muted-foreground">
{statusIcon(statusKind)}
<span>
{isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(status?.status ?? null))}
{!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number'
{isSsh && (
<span className="typography-micro flex-shrink-0 px-1 rounded leading-none pb-px text-[var(--status-info)] bg-[var(--status-info)]/10">
SSH
</span>
)}
{isActive && (
<span className="typography-micro flex-shrink-0 text-muted-foreground">{t('desktopHostSwitcher.header.current')}</span>
)}
</div>
<span className="inline-flex min-w-0 flex-1 items-center gap-1 typography-micro text-muted-foreground">
<span className="flex-shrink-0">{statusIcon(statusKind)}</span>
<span className="truncate">
{isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(statusKind))}
{!isSsh && statusKind === 'ok' && typeof status?.latencyMs === 'number'
? t('desktopHostSwitcher.status.ping', { ms: Math.max(0, Math.round(status.latencyMs)) })
: ''}
</span>
@@ -841,52 +894,6 @@ export function DesktopHostSwitcherDialog({
</button>
<div className="flex items-center gap-2 flex-shrink-0">
{!isLocal && !isSsh && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="h-8 w-8 rounded-md inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
aria-label={t('desktopHostSwitcher.actions.instanceActionsAria')}
disabled={isSaving}
onClick={(e) => e.stopPropagation()}
>
<Icon name="more-2" className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
beginEdit(host);
}}
disabled={isSaving}
>
<Icon name="pencil" className="h-4 w-4 mr-1" />
{t('desktopHostSwitcher.actions.edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
void deleteHost(host.id);
}}
className="text-destructive focus:text-destructive"
disabled={isSaving}
>
<Icon name="delete-bin" className="h-4 w-4 mr-1" />
{t('desktopHostSwitcher.actions.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{isLocal && (
<div
className="h-8 w-8 opacity-0 pointer-events-none"
aria-hidden="true"
/>
)}
{isSsh && !isLocal && (
(sshStatus?.phase === 'idle' || !sshStatus?.phase) ? (
<Button
@@ -923,7 +930,7 @@ export function DesktopHostSwitcherDialog({
)}
onClick={() => void setDefault(host.id)}
aria-label={isDefault ? t('desktopHostSwitcher.actions.defaultInstanceAria') : t('desktopHostSwitcher.actions.setAsDefaultAria')}
disabled={isSaving || (!isDefault && (statusKind === 'unreachable' || statusKind === 'wrong-service'))}
disabled={isSaving || (!isDefault && isBlockedDisplayStatus(statusKind))}
>
{isDefault ? <Icon name="star-fill" className="h-4 w-4" /> : <Icon name="star" className="h-4 w-4" />}
</button>
@@ -939,7 +946,7 @@ export function DesktopHostSwitcherDialog({
type="button"
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
statusKind === 'unreachable' || statusKind === 'wrong-service'
isBlockedDisplayStatus(statusKind)
? 'text-muted-foreground/30 cursor-not-allowed'
: 'text-muted-foreground/60 hover:text-foreground',
)}
@@ -947,14 +954,14 @@ export function DesktopHostSwitcherDialog({
e.stopPropagation();
openInNewWindow(host);
}}
disabled={statusKind === 'unreachable' || statusKind === 'wrong-service'}
disabled={isBlockedDisplayStatus(statusKind)}
aria-label={t('desktopHostSwitcher.actions.openInNewWindowAria')}
>
<Icon name="window" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{(statusKind === 'unreachable' || statusKind === 'wrong-service')
{isBlockedDisplayStatus(statusKind)
? t('desktopHostSwitcher.state.instanceUnreachable')
: t('desktopHostSwitcher.actions.openInNewWindow')}
</TooltipContent>
@@ -1000,68 +1007,16 @@ export function DesktopHostSwitcherDialog({
</div>
)}
{embedded && !isAddFormOpen ? (
<div className="flex-shrink-0 border-t border-[var(--interactive-border)]">
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-2 text-left text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
onClick={() => setIsAddFormOpen(true)}
disabled={!tauriAvailable || isSaving}
>
<Icon name="add" className="h-4 w-4" />
<span className="typography-ui-label">{t('desktopHostSwitcher.actions.addInstance')}</span>
</button>
</div>
) : (
<div className={cn(
'flex-shrink-0',
embedded
? 'border-t border-[var(--interactive-border)] px-2 py-2'
: 'rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2.5'
)}>
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">{t('desktopHostSwitcher.add.title')}</div>
<div className="flex items-center gap-2">
{embedded && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsAddFormOpen(false)}
disabled={isSaving}
>
{t('desktopHostSwitcher.actions.cancel')}
</Button>
)}
<Button
type="button"
size="sm"
onClick={() => void addHost()}
disabled={!tauriAvailable || isSaving || !newUrl.trim()}
>
{isSaving ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : null}
{t('desktopHostSwitcher.actions.add')}
</Button>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder={t('desktopHostSwitcher.field.labelOptionalPlaceholder')}
disabled={!tauriAvailable || isSaving}
/>
<Input
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder={t('desktopHostSwitcher.field.urlPlaceholder')}
disabled={!tauriAvailable || isSaving}
/>
</div>
</div>
)}
<div className="flex-shrink-0 border-t border-[var(--interactive-border)]">
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-2 text-left text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
onClick={openRemoteInstancesSettings}
>
<Icon name="add" className="h-4 w-4" />
<span className="typography-ui-label">{t('desktopHostSwitcher.actions.addInstance')}</span>
</button>
</div>
{error && (
<div className="flex-shrink-0 typography-meta text-status-error">{error}</div>
@@ -1102,7 +1057,7 @@ export function DesktopHostSwitcherDialog({
type="button"
size="sm"
variant="outline"
onClick={switchToLocal}
onClick={() => void switchToLocal()}
>
{t('desktopHostSwitcher.actions.switchToLocal')}
</Button>
@@ -1152,6 +1107,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
const [open, setOpen] = React.useState(false);
const [label, setLabel] = React.useState('Local');
const [status, setStatus] = React.useState<HostProbeResult['status'] | null>(null);
const [localOrigin, setLocalOrigin] = React.useState<string>(() => getLocalOrigin());
const attemptedDefaultSshConnectRef = React.useRef(false);
const [startupSshModal, setStartupSshModal] = React.useState<{
open: boolean;
@@ -1190,7 +1146,11 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
if (!localUrl) {
throw new Error('Connected but missing forwarded URL');
}
window.location.assign(toNavigationUrl(localUrl));
if (isElectronShell()) {
switchRuntimeEndpoint({ apiBaseUrl: localUrl, clientToken: null, runtimeKey: `ssh:${hostId}` });
} else {
window.location.assign(toNavigationUrl(localUrl));
}
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -1214,12 +1174,24 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
connecting: false,
});
let nextLocalOrigin = localOrigin;
await desktopHostsGet()
.then((cfg) => desktopHostsSet({ hosts: cfg.hosts, defaultHostId: LOCAL_HOST_ID }))
.then((cfg) => {
if (cfg.localOrigin) {
nextLocalOrigin = cfg.localOrigin;
setLocalOrigin(cfg.localOrigin);
}
return desktopHostsSet({ hosts: cfg.hosts, defaultHostId: LOCAL_HOST_ID });
})
.catch(() => undefined);
window.location.assign(toNavigationUrl(getLocalOrigin()));
}, []);
if (isElectronShell()) {
const clientToken = await getLocalClientToken();
switchRuntimeEndpoint({ apiBaseUrl: nextLocalOrigin, clientToken: clientToken || null, runtimeKey: 'local' });
} else {
window.location.assign(toNavigationUrl(nextLocalOrigin));
}
}, [localOrigin]);
const retryStartupSsh = React.useCallback(() => {
const hostId = startupSshModal.hostId;
@@ -1236,11 +1208,16 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
const run = async () => {
try {
const cfg = await desktopHostsGet();
const local = buildLocalHost();
const nextLocalOrigin = cfg.localOrigin || localOrigin;
if (cfg.localOrigin && cfg.localOrigin !== localOrigin) {
setLocalOrigin(cfg.localOrigin);
}
const local = buildLocalHost(nextLocalOrigin);
const all = [local, ...(cfg.hosts || [])];
const current = resolveCurrentHost(all);
if (
!isElectronShell() &&
!attemptedDefaultSshConnectRef.current &&
current.id === LOCAL_HOST_ID &&
cfg.defaultHostId &&
@@ -1290,13 +1267,16 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
cancelled = true;
window.clearInterval(interval);
};
}, [connectDefaultSshInstance, t]);
}, [connectDefaultSshInstance, localOrigin, t]);
if (!isDesktopShell()) {
return null;
}
const isCurrentlyLocal = locationMatchesHost(window.location.href, getLocalOrigin());
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
const isCurrentlyLocal = runtimeApiBaseUrl
? locationMatchesHost(runtimeApiBaseUrl, localOrigin)
: locationMatchesHost(window.location.href, localOrigin);
const fallbackLabel = typeof window !== 'undefined' && window.location.hostname
? window.location.hostname
@@ -19,6 +19,10 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { ContextPanelContent } from './ContextSidebarTab';
import { toast } from '@/components/ui';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
import { invokeDesktopCommand } from '@/lib/desktopNative';
@@ -436,15 +440,42 @@ type PreviewPaneProps = {
type PreviewProxyState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; proxyBasePath: string; expiresAt: number }
| { status: 'ready'; proxyBasePath: string; previewToken?: string; expiresAt: number }
| { status: 'error'; message: string };
const getPreviewProxyOrigin = (proxySrc: string): string => {
if (typeof window === 'undefined') return '';
try {
return new URL(proxySrc || window.location.href, window.location.href).origin;
} catch {
return window.location.origin;
}
};
const postPreviewBridgeMessage = (frameWindow: Window, proxySrc: string, payload: Record<string, unknown>): void => {
const targetOrigin = getPreviewProxyOrigin(proxySrc);
frameWindow.postMessage(payload, targetOrigin);
};
const stripPreviewTokenFromUrl = (value: string): string => {
if (!value) return value;
try {
const parsed = new URL(value);
parsed.searchParams.delete('oc_preview_token');
parsed.searchParams.delete('oc_client_token');
parsed.searchParams.delete('oc_url_token');
return parsed.toString();
} catch {
return value;
}
};
const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const [reloadNonce, bumpReload] = React.useReducer((x: number) => x + 1, 0);
const [proxyRegistrationNonce, bumpProxyRegistration] = React.useReducer((x: number) => x + 1, 0);
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ status: 'idle' });
const [urlAuthReadyKey, setUrlAuthReadyKey] = React.useState('');
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const nextConsoleEventIdRef = React.useRef(1);
const [bridgeReady, setBridgeReady] = React.useState(false);
@@ -480,6 +511,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
: null;
const targetKey = normalizedUrl ? normalizedUrl.toString() : '';
const proxyCacheKey = targetKey ? `${getRuntimeApiBaseUrl() || 'same-origin'}|${targetKey}` : '';
const previewColorScheme = currentTheme.metadata.variant;
React.useEffect(() => {
@@ -488,18 +520,21 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
return;
}
const cached = getCachedProxyTarget(targetKey);
if (cached) {
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, expiresAt: cached.expiresAt });
const cached = getCachedProxyTarget(proxyCacheKey);
if (cached?.previewToken) {
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt });
return;
}
if (cached) {
previewProxyTargetCache.delete(proxyCacheKey);
}
let cancelled = false;
setProxyState({ status: 'loading' });
void (async () => {
try {
const response = await fetch('/api/preview/targets', {
const response = await runtimeFetch('/api/preview/targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -507,7 +542,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
});
if (!response.ok) {
previewProxyTargetCache.delete(targetKey);
previewProxyTargetCache.delete(proxyCacheKey);
const errorBody = await response.json().catch(() => ({}));
const message = typeof errorBody?.error === 'string'
? errorBody.error
@@ -518,23 +553,24 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
return;
}
const body = await response.json() as { proxyBasePath?: unknown; expiresAt?: unknown };
const body = await response.json() as { proxyBasePath?: unknown; previewToken?: unknown; expiresAt?: unknown };
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
const previewToken = typeof body.previewToken === 'string' ? body.previewToken : '';
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
if (!proxyBasePath) {
previewProxyTargetCache.delete(targetKey);
if (!proxyBasePath || !previewToken) {
previewProxyTargetCache.delete(proxyCacheKey);
if (!cancelled) {
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
}
return;
}
previewProxyTargetCache.set(targetKey, { proxyBasePath, expiresAt });
previewProxyTargetCache.set(proxyCacheKey, { proxyBasePath, previewToken, expiresAt });
if (!cancelled) {
setProxyState({ status: 'ready', proxyBasePath, expiresAt });
setProxyState({ status: 'ready', proxyBasePath, previewToken, expiresAt });
}
} catch (error) {
previewProxyTargetCache.delete(targetKey);
previewProxyTargetCache.delete(proxyCacheKey);
if (!cancelled) {
const message = error instanceof Error ? error.message : String(error);
setProxyState({ status: 'error', message });
@@ -545,27 +581,51 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
return () => {
cancelled = true;
};
}, [isLoopback, proxyRegistrationNonce, t, targetKey]);
}, [isLoopback, proxyCacheKey, proxyRegistrationNonce, t, targetKey]);
const directSrc = normalizedUrl
&& (normalizedUrl.protocol === 'http:' || normalizedUrl.protocol === 'https:')
? normalizedUrl.toString()
: '';
const proxySrc = isLoopback && proxyState.status === 'ready' && normalizedUrl
const proxyUrlAuthKey = isLoopback && proxyState.status === 'ready'
? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
: '';
React.useEffect(() => {
if (!proxyUrlAuthKey) {
setUrlAuthReadyKey('');
return;
}
let cancelled = false;
setUrlAuthReadyKey('');
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
.then((token) => {
if (!cancelled && token) setUrlAuthReadyKey(proxyUrlAuthKey);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [proxyUrlAuthKey]);
const proxySrc = isLoopback && proxyState.status === 'ready' && normalizedUrl && urlAuthReadyKey === proxyUrlAuthKey
? (() => {
const path = normalizedUrl.pathname || '/';
const searchParams = new URLSearchParams(normalizedUrl.search);
searchParams.set('ocPreview', String(reloadNonce));
searchParams.set('oc_preview_token', proxyState.previewToken || '');
const search = searchParams.toString();
const hash = normalizedUrl.hash || '';
return `${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${hash}`;
return getRuntimeUrlResolver().authenticatedAsset(`${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${hash}`);
})()
: '';
const effectiveSrc = isLoopback ? proxySrc : directSrc;
const headerSrc = effectiveSrc || directSrc;
const showLoading = isLoopback && (proxyState.status === 'loading' || proxyState.status === 'idle');
const headerSrc = isLoopback ? stripPreviewTokenFromUrl(proxySrc) : directSrc;
const showLoading = isLoopback && (proxyState.status === 'loading' || proxyState.status === 'idle' || urlAuthReadyKey !== proxyUrlAuthKey);
const showError = isLoopback && proxyState.status === 'error';
const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => {
@@ -630,26 +690,26 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
if (!bridgeReady || !frameWindow) {
return;
}
frameWindow.postMessage({
postPreviewBridgeMessage(frameWindow, proxySrc, {
source: 'openchamber-preview-parent',
version: 1,
type: 'set-inspect-mode',
enabled: inspectMode,
}, window.location.origin);
}, [bridgeReady, inspectMode]);
});
}, [bridgeReady, inspectMode, proxySrc]);
React.useEffect(() => {
const frameWindow = iframeRef.current?.contentWindow;
if (!bridgeReady || !frameWindow) {
return;
}
frameWindow.postMessage({
postPreviewBridgeMessage(frameWindow, proxySrc, {
source: 'openchamber-preview-parent',
version: 1,
type: 'set-color-scheme',
scheme: previewColorScheme,
}, window.location.origin);
}, [bridgeReady, previewColorScheme]);
});
}, [bridgeReady, previewColorScheme, proxySrc]);
React.useEffect(() => {
if (!inspectMode || typeof window === 'undefined') return;
@@ -860,7 +920,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
void (async () => {
const probe = async (): Promise<Response | null> => {
try {
return await fetch(proxySrc, {
return await runtimeFetch(proxySrc, {
method: 'GET',
credentials: 'include',
cache: 'no-store',
@@ -882,7 +942,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
}
if (response.status === 403 || response.status === 404) {
previewProxyTargetCache.delete(targetKey);
previewProxyTargetCache.delete(proxyCacheKey);
setProxyState({ status: 'loading' });
bumpProxyRegistration();
return;
@@ -918,7 +978,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
return () => {
cancelled = true;
};
}, [proxySrc, reloadNonce, targetKey]);
}, [proxyCacheKey, proxySrc, reloadNonce]);
const showUpstreamStarting = isLoopback
&& proxyState.status === 'ready'
@@ -943,7 +1003,8 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
try {
const location = frameWindow.location;
if (location.origin !== window.location.origin) {
const proxyOrigin = getPreviewProxyOrigin(proxySrc);
if (location.origin !== proxyOrigin) {
return;
}
if (location.pathname.startsWith(proxyState.proxyBasePath)) {
@@ -955,7 +1016,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
} catch {
// Cross-origin frames are expected for non-loopback/direct previews.
}
}, [isLoopback, proxyState]);
}, [isLoopback, proxySrc, proxyState]);
return (
<div className="absolute inset-0 flex flex-col">
@@ -1195,6 +1256,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
const [isInspecting, setIsInspecting] = React.useState(false);
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ status: 'idle' });
const [urlAuthReadyKey, setUrlAuthReadyKey] = React.useState('');
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
@@ -1264,10 +1326,13 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
const proxyTargetKey = getBrowserProxyTargetKey(currentUrl);
const cached = getCachedProxyTarget(proxyTargetKey);
if (cached) {
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, expiresAt: cached.expiresAt });
if (cached?.previewToken) {
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt });
return;
}
if (cached) {
previewProxyTargetCache.delete(proxyTargetKey);
}
let cancelled = false;
setProxyState({ status: 'loading' });
@@ -1275,7 +1340,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
void (async () => {
try {
const response = await fetch('/api/preview/targets', {
const response = await runtimeFetch('/api/preview/targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -1293,19 +1358,20 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
return;
}
const body = await response.json() as { proxyBasePath?: unknown; expiresAt?: unknown };
const body = await response.json() as { proxyBasePath?: unknown; previewToken?: unknown; expiresAt?: unknown };
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
const previewToken = typeof body.previewToken === 'string' ? body.previewToken : '';
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
if (!proxyBasePath) {
if (!proxyBasePath || !previewToken) {
if (!cancelled) {
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
}
return;
}
previewProxyTargetCache.set(proxyTargetKey, { proxyBasePath, expiresAt });
previewProxyTargetCache.set(proxyTargetKey, { proxyBasePath, previewToken, expiresAt });
if (!cancelled) {
setProxyState({ status: 'ready', proxyBasePath, expiresAt });
setProxyState({ status: 'ready', proxyBasePath, previewToken, expiresAt });
}
} catch (error) {
if (!cancelled) {
@@ -1320,16 +1386,44 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
};
}, [currentUrl, t]);
const proxyUrlAuthKey = currentUrl && proxyState.status === 'ready'
? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
: '';
React.useEffect(() => {
if (!proxyUrlAuthKey) {
setUrlAuthReadyKey('');
return;
}
let cancelled = false;
setUrlAuthReadyKey('');
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
.then((token) => {
if (!cancelled && token) setUrlAuthReadyKey(proxyUrlAuthKey);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [proxyUrlAuthKey]);
const proxySrc = React.useMemo(() => {
if (urlAuthReadyKey !== proxyUrlAuthKey) return '';
if (!currentUrl || proxyState.status !== 'ready') return '';
try {
const parsed = new URL(currentUrl);
const path = parsed.pathname || '/';
return `${proxyState.proxyBasePath}${path}${parsed.search}${parsed.hash}`;
const searchParams = new URLSearchParams(parsed.search);
searchParams.set('ocPreview', String(reloadNonce));
searchParams.set('oc_preview_token', proxyState.previewToken || '');
const search = searchParams.toString();
return getRuntimeUrlResolver().authenticatedAsset(`${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${parsed.hash}`);
} catch {
return '';
}
}, [currentUrl, proxyState]);
}, [currentUrl, proxyState, proxyUrlAuthKey, reloadNonce, urlAuthReadyKey]);
const iframeSrc = proxySrc || (proxyState.status === 'error' ? currentUrl : '');
+187 -24
View File
@@ -32,6 +32,7 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
import { cn, hasModifier } from '@/lib/utils';
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
@@ -62,11 +63,14 @@ import { forceKillTerminal } from '@/lib/terminalApi';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop';
import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { resolveSessionDiffStats } from '@/components/session/sidebar/utils';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import type { Session } from '@opencode-ai/sdk/v2/client';
import type { IconName } from "@/components/icon/icons";
@@ -323,6 +327,7 @@ type DesktopServicesMenuProps = {
isDesktopApp: boolean;
currentInstanceLabel: string;
compactCurrentInstanceLabel: string;
currentInstanceIsLocal: boolean;
isDesktopServicesOpen: boolean;
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
refreshCurrentInstanceLabel: () => Promise<void>;
@@ -346,6 +351,10 @@ type DesktopServicesMenuProps = {
showDevShutdown: boolean;
isDevShutdownInFlight: boolean;
onDevShutdown: () => Promise<void>;
remoteUpdateInfo: UpdateInfo | null;
remoteUpdateChecking: boolean;
remoteUpdateError: string | null;
onOpenRemoteUpdate: () => void;
showPredValues: boolean;
};
@@ -353,6 +362,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
isDesktopApp,
currentInstanceLabel,
compactCurrentInstanceLabel,
currentInstanceIsLocal,
isDesktopServicesOpen,
setIsDesktopServicesOpen,
refreshCurrentInstanceLabel,
@@ -376,6 +386,10 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
showDevShutdown,
isDevShutdownInFlight,
onDevShutdown,
remoteUpdateInfo,
remoteUpdateChecking,
remoteUpdateError,
onOpenRemoteUpdate,
showPredValues,
}: DesktopServicesMenuProps) {
const { t } = useI18n();
@@ -453,12 +467,39 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
</div>
{isDesktopApp && desktopServicesTab === 'instance' ? (
<DesktopHostSwitcherDialog
embedded
open={isDesktopServicesOpen && desktopServicesTab === 'instance'}
onOpenChange={() => {}}
onHostSwitched={() => setIsDesktopServicesOpen(false)}
/>
<div>
{!currentInstanceIsLocal ? (
<div className="border-b border-[var(--interactive-border)] px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="typography-ui-label font-medium text-foreground">{t('header.services.remoteUpdate.title')}</div>
<div className="typography-micro text-muted-foreground">
{remoteUpdateInfo?.available
? t('header.services.remoteUpdate.available', { version: remoteUpdateInfo.version || '' })
: remoteUpdateChecking
? t('header.services.remoteUpdate.checking')
: remoteUpdateError || t('header.services.remoteUpdate.upToDate')}
</div>
</div>
{remoteUpdateInfo?.available ? (
<button
type="button"
className="shrink-0 rounded-md bg-[var(--primary-base)] px-3 py-1.5 typography-ui-label font-medium text-[var(--primary-foreground)] hover:opacity-90"
onClick={onOpenRemoteUpdate}
>
{t('header.services.remoteUpdate.actions.open')}
</button>
) : null}
</div>
</div>
) : null}
<DesktopHostSwitcherDialog
embedded
open={isDesktopServicesOpen && desktopServicesTab === 'instance'}
onOpenChange={() => {}}
onHostSwitched={() => setIsDesktopServicesOpen(false)}
/>
</div>
) : null}
{desktopServicesTab === 'mcp' ? (
@@ -889,6 +930,11 @@ export const Header: React.FC<HeaderProps> = ({
const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false);
const [isUsageRefreshSpinning, setIsUsageRefreshSpinning] = React.useState(false);
const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local');
const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true);
const [remoteUpdateDialogOpen, setRemoteUpdateDialogOpen] = React.useState(false);
const [remoteUpdateInfo, setRemoteUpdateInfo] = React.useState<UpdateInfo | null>(null);
const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false);
const [remoteUpdateError, setRemoteUpdateError] = React.useState<string | null>(null);
const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]);
const [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>(
isDesktopApp ? 'instance' : 'usage'
@@ -912,17 +958,25 @@ export const Header: React.FC<HeaderProps> = ({
}
try {
const cfg = await desktopHostsGet();
const currentHref = window.location.href;
const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
if (locationMatchesHost(currentHref, localOrigin)) {
if (isDesktopLocalOriginActive()) {
setCurrentInstanceLabel('Local');
setCurrentInstanceIsLocal(true);
return;
}
setCurrentInstanceIsLocal(false);
const cfg = await desktopHostsGet();
const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) {
setCurrentInstanceLabel('Local');
setCurrentInstanceIsLocal(true);
return;
}
const match = cfg.hosts.find((host) => {
return locationMatchesHost(currentHref, host.url);
return runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false;
});
if (match?.label?.trim()) {
@@ -933,12 +987,98 @@ export const Header: React.FC<HeaderProps> = ({
setCurrentInstanceLabel('Instance');
} catch {
setCurrentInstanceLabel('Local');
setCurrentInstanceIsLocal(true);
}
}, [isDesktopApp]);
useEffect(() => {
void refreshCurrentInstanceLabel();
}, [refreshCurrentInstanceLabel]);
const checkRemoteInstanceUpdate = React.useCallback(async () => {
if (currentInstanceIsLocal) {
setRemoteUpdateInfo(null);
setRemoteUpdateError(null);
return;
}
setRemoteUpdateChecking(true);
setRemoteUpdateError(null);
try {
const params = new URLSearchParams({ appType: 'web', instanceMode: 'remote' });
const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
const data = await response.json();
setRemoteUpdateInfo({
available: data.available ?? false,
version: data.version,
currentVersion: data.currentVersion ?? 'unknown',
body: data.body,
nextSuggestedCheckInSec: typeof data.nextSuggestedCheckInSec === 'number' ? data.nextSuggestedCheckInSec : undefined,
packageManager: data.packageManager,
updateCommand: data.updateCommand,
});
} catch (error) {
setRemoteUpdateInfo(null);
setRemoteUpdateError(error instanceof Error ? error.message : t('header.services.remoteUpdate.error'));
} finally {
setRemoteUpdateChecking(false);
}
}, [currentInstanceIsLocal, t]);
React.useEffect(() => {
setRemoteUpdateInfo(null);
setRemoteUpdateError(null);
setRemoteUpdateDialogOpen(false);
}, [currentInstanceIsLocal, currentInstanceLabel]);
React.useEffect(() => {
if (!isDesktopApp || currentInstanceIsLocal) {
return;
}
const initialDelayMs = 3000;
const intervalMs = 60 * 60 * 1000;
let disposed = false;
let timer: number | null = null;
const schedule = (delayMs: number) => {
timer = window.setTimeout(() => {
if (disposed || (typeof document !== 'undefined' && document.visibilityState !== 'visible')) {
schedule(intervalMs);
return;
}
void checkRemoteInstanceUpdate().finally(() => {
if (!disposed) {
schedule(intervalMs);
}
});
}, delayMs);
};
schedule(initialDelayMs);
return () => {
disposed = true;
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [checkRemoteInstanceUpdate, currentInstanceIsLocal, currentInstanceLabel, isDesktopApp]);
const openRemoteInstanceUpdate = React.useCallback(() => {
if (remoteUpdateInfo?.available) {
setRemoteUpdateDialogOpen(true);
return;
}
void checkRemoteInstanceUpdate();
}, [checkRemoteInstanceUpdate, remoteUpdateInfo?.available]);
useQuotaAutoRefresh();
const selectedModels = useQuotaStore((state) => state.selectedModels);
const expandedFamilies = useQuotaStore((state) => state.expandedFamilies);
@@ -1300,7 +1440,7 @@ export const Header: React.FC<HeaderProps> = ({
const payload = runtimeApis.github
? await runtimeApis.github.authActivate(accountId)
: await (async () => {
const response = await fetch('/api/github/auth/activate', {
const response = await runtimeFetch('/api/github/auth/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1366,6 +1506,8 @@ export const Header: React.FC<HeaderProps> = ({
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: normalize(openDirectory || activeProject?.path || ''),
projectId: activeProject?.id ?? null,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open draft mini chat window', error);
});
@@ -1383,6 +1525,8 @@ export const Header: React.FC<HeaderProps> = ({
void invokeDesktop('desktop_open_session_mini_chat_window', {
sessionId: currentSessionId,
directory: normalize(openDirectory || activeProject?.path || ''),
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open session mini chat window', error);
});
@@ -1740,7 +1884,7 @@ export const Header: React.FC<HeaderProps> = ({
}
try {
const devRes = await fetch('/api/system/dev-shutdown', {
const devRes = await runtimeFetch('/api/system/dev-shutdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ previewUrls }),
@@ -1748,7 +1892,7 @@ export const Header: React.FC<HeaderProps> = ({
if (devRes.ok) {
shutdownRequested = true;
} else {
const shutdownRes = await fetch('/api/system/shutdown', { method: 'POST' });
const shutdownRes = await runtimeFetch('/api/system/shutdown', { method: 'POST' });
shutdownRequested = shutdownRes.ok;
}
} catch {
@@ -1929,6 +2073,7 @@ export const Header: React.FC<HeaderProps> = ({
isDesktopApp={isDesktopApp}
currentInstanceLabel={currentInstanceLabel}
compactCurrentInstanceLabel={compactCurrentInstanceLabel}
currentInstanceIsLocal={currentInstanceIsLocal}
isDesktopServicesOpen={isDesktopServicesOpen}
setIsDesktopServicesOpen={setIsDesktopServicesOpen}
refreshCurrentInstanceLabel={refreshCurrentInstanceLabel}
@@ -1953,6 +2098,10 @@ export const Header: React.FC<HeaderProps> = ({
showDevShutdown={showDevShutdown}
isDevShutdownInFlight={isDevShutdownInFlight}
onDevShutdown={handleDevShutdown}
remoteUpdateInfo={remoteUpdateInfo}
remoteUpdateChecking={remoteUpdateChecking}
remoteUpdateError={remoteUpdateError}
onOpenRemoteUpdate={openRemoteInstanceUpdate}
/>
<HeaderIconActionButton
title={t('header.actions.terminalPanelWithShortcut', { shortcut: shortcutLabel('toggle_terminal') })}
@@ -2533,12 +2682,26 @@ export const Header: React.FC<HeaderProps> = ({
);
return (
<header
ref={headerRef}
className={headerClassName}
style={{ ['--padding-scale' as string]: '1' } as React.CSSProperties}
>
{isMobile ? renderMobile() : renderDesktop()}
</header>
<>
<header
ref={headerRef}
className={headerClassName}
style={{ ['--padding-scale' as string]: '1' } as React.CSSProperties}
>
{isMobile ? renderMobile() : renderDesktop()}
</header>
<UpdateDialog
open={remoteUpdateDialogOpen}
onOpenChange={setRemoteUpdateDialogOpen}
info={remoteUpdateInfo}
downloading={false}
downloaded={false}
progress={null}
error={remoteUpdateError}
onDownload={() => {}}
onRestart={() => {}}
runtimeType="web"
/>
</>
);
};
@@ -24,13 +24,13 @@ import { cn } from '@/lib/utils';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { ChatView } from '@/components/views/ChatView';
import { DiffView } from '@/components/views/DiffView';
import { FilesView } from '@/components/views/FilesView';
import { GitView } from '@/components/views/GitView';
import { PlanView } from '@/components/views/PlanView';
// Heavy views loaded on-demand to reduce initial bundle parse time.
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
@@ -146,19 +146,8 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
const hasCustomIcon = currentIconImage?.source === 'custom';
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
const hasRemovableImageIcon = effectiveHasImageIcon;
const iconPreviewUrl = !previewImageFailed
? (hasPendingUploadImageIcon
? pendingUploadIconPreviewUrl
: (hasStoredImageIcon && !pendingRemoveImageIcon
? getProjectIconImageUrl(
{ id: projectId, iconImage: currentIconImage ?? null },
{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
},
)
: null))
: null;
const showStoredImagePreview = hasStoredImageIcon && !pendingRemoveImageIcon;
const showImagePreview = !previewImageFailed && (hasPendingUploadImageIcon || showStoredImagePreview);
React.useEffect(() => {
setPreviewImageFailed(false);
@@ -352,7 +341,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
);
})}
</div>
{effectiveHasImageIcon && iconPreviewUrl && (
{effectiveHasImageIcon && showImagePreview && (
<div className="flex items-center gap-2 pt-1">
<span className="typography-meta text-muted-foreground">{t('projectEditDialog.field.preview')}</span>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-[var(--surface-elevated)] p-1">
@@ -360,13 +349,25 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
<img
src={iconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
{hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? (
<img
src={pendingUploadIconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
) : (
<ProjectIconImage
project={{ id: projectId, iconImage: currentIconImage }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
onError={() => setPreviewImageFailed(true)}
/>
)}
</span>
</span>
</div>
@@ -166,21 +166,20 @@ export function MultiRunFusionDialog({
useSessionUIStore.getState().setCurrentSession(fusionSession.id, directory);
onOpenChange(false);
await opencodeClient.withDirectory(directory ?? opencodeClient.getDirectory(), () =>
opencodeClient.sendMessage({
id: fusionSession.id,
providerID,
modelID,
variant: variant || undefined,
agent: agent || undefined,
text: visiblePrompt,
additionalParts: [
{ text: instructionsPrompt, synthetic: true },
...usableSources.map((item, index) => ({ text: buildSourcePart(item.source, item.text, index), synthetic: true })),
{ text: '\n\n--- FUSION INPUTS END ---\nNow write the final fused answer.', synthetic: true },
],
})
);
await opencodeClient.sendMessage({
id: fusionSession.id,
providerID,
modelID,
variant: variant || undefined,
agent: agent || undefined,
text: visiblePrompt,
additionalParts: [
{ text: instructionsPrompt, synthetic: true },
...usableSources.map((item, index) => ({ text: buildSourcePart(item.source, item.text, index), synthetic: true })),
{ text: '\n\n--- FUSION INPUTS END ---\nNow write the final fused answer.', synthetic: true },
],
directory: directory ?? opencodeClient.getDirectory(),
});
} catch (error) {
console.error('[MultiRunFusion] Failed to start fusion', error);
toast.error(t('multirun.fusion.toast.failed'));
@@ -26,7 +26,7 @@ import { Icon } from "@/components/icon/Icon";
import { isDesktopShell } from '@/lib/desktop';
import { useTabletStandalonePwaRuntime } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import type { ProjectEntry } from '@/lib/api/types';
import { startDesktopWindowDrag } from '@/lib/desktopNative';
import { useI18n } from '@/lib/i18n';
@@ -145,30 +145,32 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
const renderProjectLabel = React.useCallback((project: ProjectEntry) => {
const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory);
const imageUrl = getProjectIconImageUrl(
{ id: project.id, iconImage: project.iconImage ?? null },
{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
},
);
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined;
const fallbackIcon = projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
);
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{imageUrl ? (
{project.iconImage ? (
<span
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
<ProjectIconImage
project={{ id: project.id, iconImage: project.iconImage ?? null }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
)}
) : fallbackIcon}
<span className="truncate">{displayLabel}</span>
</span>
);
@@ -10,6 +10,7 @@ import { cn } from '@/lib/utils';
import { RemoteConnectionForm } from './RemoteConnectionForm';
import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const DOCS_URL = 'https://opencode.ai/docs';
@@ -78,7 +79,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
let cancelled = false;
void (async () => {
try {
const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
if (!data || cancelled) return;
@@ -105,7 +106,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
const checkCliAvailability = React.useCallback(async (): Promise<boolean> => {
try {
const response = await fetch('/health');
const response = await runtimeFetch('/health');
if (!response.ok) return false;
const data = await response.json();
return data.openCodeRunning === true || data.isOpenCodeReady === true;
@@ -206,7 +207,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
await restartDesktopApp();
return;
}
await fetch('/api/config/reload', { method: 'POST' });
await runtimeFetch('/api/config/reload', { method: 'POST' });
} finally {
setTimeout(() => setIsApplyingPath(false), 1000);
}
@@ -50,7 +50,7 @@ export function DesktopConnectionRecovery({
if (variant === 'remote-unreachable') {
return { host: t('onboarding.desktopRecovery.placeholders.remoteServer') };
}
if (variant === 'remote-wrong-service') {
if (variant === 'remote-wrong-service' || variant === 'remote-incompatible') {
return { host: t('onboarding.desktopRecovery.placeholders.unknownServer') };
}
return undefined;
@@ -84,7 +84,7 @@ export function DesktopConnectionRecovery({
</div>
{/* Host info if available */}
{hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service') && (
{hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service' || variant === 'remote-incompatible') && (
<div className="rounded-lg border border-border bg-background/50 p-3">
<div className="text-xs text-muted-foreground mb-1">{t('onboarding.remoteConnection.field.serverAddress')}</div>
<div className="font-mono text-sm text-foreground truncate">{redactSensitiveUrl(hostUrl)}</div>
@@ -7,6 +7,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
import { restartDesktopApp } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const DOCS_URL = 'https://opencode.ai/docs';
@@ -99,7 +100,7 @@ export function LocalSetupScreen({
let cancelled = false;
void (async () => {
try {
const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
if (!data || cancelled) return;
@@ -134,7 +135,7 @@ export function LocalSetupScreen({
const checkCliAvailability = React.useCallback(async (): Promise<boolean> => {
try {
const response = await fetch('/health');
const response = await runtimeFetch('/health');
if (!response.ok) return false;
const data = await response.json();
return data.openCodeRunning === true || data.isOpenCodeReady === true;
@@ -182,7 +183,7 @@ export function LocalSetupScreen({
return;
}
await fetch('/api/config/reload', { method: 'POST' });
await runtimeFetch('/api/config/reload', { method: 'POST' });
} finally {
setTimeout(() => setIsRetrying(false), 1000);
}
@@ -4,6 +4,7 @@ import { DesktopConnectionRecovery, type RecoveryVariant } from './DesktopConnec
import { RemoteConnectionForm } from './RemoteConnectionForm';
import { resolveRecoveryNextStep } from './desktopRecoveryRouting';
import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
import { runtimeFetch } from '@/lib/runtime-fetch';
type RecoveryScreenProps = {
/** Recovery variant */
@@ -62,7 +63,7 @@ export function RecoveryScreen({
return;
}
await fetch('/api/config/reload', { method: 'POST' });
await runtimeFetch('/api/config/reload', { method: 'POST' });
onRetry?.();
}, [onRetry]);
@@ -3,7 +3,7 @@ import {
desktopHostsGet,
desktopHostsSet,
desktopHostProbe,
normalizeHostUrl,
resolveDesktopHostUrl,
type HostProbeResult,
} from '@/lib/desktopHosts';
import { Button } from '@/components/ui/button';
@@ -37,6 +37,10 @@ function getProbeStatusMessageKey(status: ProbeStatus): string | null {
return null; // Success is shown separately
case 'auth':
return 'onboarding.remoteConnection.probe.authMessage';
case 'update-recommended':
return 'onboarding.remoteConnection.probe.updateRecommendedMessage';
case 'incompatible':
return 'onboarding.remoteConnection.probe.incompatibleMessage';
case 'wrong-service':
return 'onboarding.remoteConnection.probe.wrongServiceMessage';
case 'unreachable':
@@ -47,7 +51,7 @@ function getProbeStatusMessageKey(status: ProbeStatus): string | null {
}
function isBlockingStatus(status: ProbeStatus): boolean {
return status === 'wrong-service' || status === 'unreachable';
return status === 'wrong-service' || status === 'unreachable' || status === 'incompatible';
}
export function RemoteConnectionForm({
@@ -66,7 +70,8 @@ export function RemoteConnectionForm({
const [probeResult, setProbeResult] = useState<HostProbeResult | null>(null);
const [error, setError] = useState('');
const normalizedUrl = normalizeHostUrl(url);
const resolvedUrl = resolveDesktopHostUrl(url);
const normalizedUrl = resolvedUrl?.persistedUrl ?? null;
const handleUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value);
@@ -89,7 +94,7 @@ export function RemoteConnectionForm({
try {
const result = await desktopHostProbe(normalizedUrl);
setProbeResult(result);
setState(result.status === 'ok' ? 'success' : 'error');
setState(result.status === 'ok' || result.status === 'update-recommended' ? 'success' : 'error');
} catch (err) {
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.connectionTestFailed'));
setState('error');
@@ -97,14 +102,15 @@ export function RemoteConnectionForm({
}, [normalizedUrl, t]);
const handleConnect = useCallback(async () => {
if (!normalizedUrl) return;
if (!resolvedUrl) return;
const targetUrl = resolvedUrl.persistedUrl;
setState('testing');
setProbeResult(null);
setError('');
try {
const probe = await desktopHostProbe(normalizedUrl);
const probe = await desktopHostProbe(targetUrl);
setProbeResult(probe);
// Block connection on wrong-service or unreachable
@@ -114,10 +120,10 @@ export function RemoteConnectionForm({
}
const config = await desktopHostsGet();
const hostLabel = label.trim() || normalizedUrl;
const hostLabel = label.trim() || targetUrl;
const existingHost = config.hosts.find(
(h) => h.url === normalizedUrl
(h) => h.url === targetUrl
);
const hostId = existingHost ? existingHost.id : `host-${Date.now().toString(16)}`;
@@ -125,7 +131,8 @@ export function RemoteConnectionForm({
const newHost = {
id: hostId,
label: hostLabel,
url: normalizedUrl,
url: targetUrl,
apiUrl: targetUrl,
};
const updatedHosts = existingHost
@@ -141,6 +148,11 @@ export function RemoteConnectionForm({
onConnect?.();
if (resolvedUrl.redeemUrl) {
window.location.assign(resolvedUrl.redeemUrl);
return;
}
if (isTauriShell()) {
const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
await tauri?.core?.invoke?.('desktop_restart');
@@ -149,7 +161,7 @@ export function RemoteConnectionForm({
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.failedToSaveConnection'));
setState('error');
}
}, [normalizedUrl, label, onConnect, t]);
}, [resolvedUrl, label, onConnect, t]);
const isTesting = state === 'testing';
const canTest = normalizedUrl !== null && !isTesting;
@@ -157,6 +169,7 @@ export function RemoteConnectionForm({
const probeMessageKey = getProbeStatusMessageKey(probeResult?.status ?? null);
const isSuccess = probeResult?.status === 'ok';
const isUpdateRecommended = probeResult?.status === 'update-recommended';
const isAuth = probeResult?.status === 'auth';
const isBlocking = isBlockingStatus(probeResult?.status ?? null);
@@ -238,6 +251,18 @@ export function RemoteConnectionForm({
</div>
)}
{probeResult && isUpdateRecommended && (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--status-warning)',
color: 'var(--status-warning)',
}}
>
{probeMessageKey ? t(probeMessageKey as Parameters<typeof t>[0]) : null}
</div>
)}
{/* Blocking errors */}
{probeResult && isBlocking && (
<div
@@ -60,6 +60,15 @@ describe('getDesktopRecoveryConfig', () => {
expect(config.useRemoteLabel).toBe('Use Remote');
});
test('remote-incompatible exposes retry and both actions', () => {
const config = getDesktopRecoveryConfig('remote-incompatible', 'Old Server', 'https://old.example');
expect(config.showRetry).toBe(true);
expect(config.showUseLocal).toBe(true);
expect(config.showUseRemote).toBe(true);
expect(config.titleKey).toBe('onboarding.desktopRecovery.remoteIncompatible.title');
});
// ---------------------------------------------------------------------------
// 4. missing-default-host: chooser-with-context (both actions, no retry)
// ---------------------------------------------------------------------------
@@ -3,6 +3,7 @@ import { redactSensitiveUrl } from '@/lib/desktopHosts';
export type RecoveryVariant =
| 'local-unavailable'
| 'remote-unreachable'
| 'remote-incompatible'
| 'remote-wrong-service'
| 'remote-missing'
| 'missing-default-host';
@@ -113,6 +114,27 @@ export function getDesktopRecoveryConfig(
};
}
case 'remote-incompatible': {
const host = formatHostDisplay(hostLabel, hostUrl);
return {
title: 'Server Update Required',
description: `The OpenChamber server at "${host || 'unknown'}" is not compatible with this app version. Update OpenChamber on the server, then try again.`,
titleKey: 'onboarding.desktopRecovery.remoteIncompatible.title',
descriptionKey: 'onboarding.desktopRecovery.remoteIncompatible.description',
descriptionParams: host ? { host } : undefined,
iconKey: 'remote',
showRetry: true,
retryLabel: 'Retry Connection',
retryLabelKey: 'onboarding.desktopRecovery.remoteUnreachable.retry',
showUseLocal: true,
showUseRemote: true,
useLocalLabel: 'Use Local',
useLocalLabelKey: 'onboarding.desktopRecovery.common.useLocal',
useRemoteLabel: 'Use Remote',
useRemoteLabelKey: 'onboarding.desktopRecovery.common.useRemote',
};
}
case 'missing-default-host':
return {
title: 'No Default Connection',
@@ -17,6 +17,10 @@ const EXPECTED_ROUTING: Record<RecoveryVariant, Record<RecoveryPrimaryAction, Re
'use-local': 'switch-default-to-local',
'use-remote': 'remote-form',
},
'remote-incompatible': {
'use-local': 'switch-default-to-local',
'use-remote': 'remote-form',
},
'remote-wrong-service': {
'use-local': 'switch-default-to-local',
'use-remote': 'remote-form',
@@ -20,6 +20,7 @@ export function resolveRecoveryNextStep(
case 'local-unavailable':
return { kind: 'local-setup' };
case 'remote-unreachable':
case 'remote-incompatible':
case 'remote-wrong-service':
case 'remote-missing':
case 'missing-default-host':
@@ -21,6 +21,7 @@ import {
type ResponseStylePreset,
} from '@/lib/responseStyle';
import type { DesktopSettings } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
const AGENTS_MD_PATH = '~/.config/opencode/AGENTS.md';
@@ -69,7 +70,7 @@ const RESPONSE_STYLE_OPTION_LABEL_KEYS: Record<ResponseStylePreset, I18nKey> = {
};
const saveBehaviorSetting = async (settings: Partial<DesktopSettings>, fallbackError: string) => {
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -104,12 +105,12 @@ export const BehaviorPage: React.FC = () => {
const load = async () => {
try {
const [settingsRes, agentsMdRes] = await Promise.all([
fetch('/api/config/settings', {
runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
signal: abort.signal,
}),
fetch('/api/behavior/agents-md', {
runtimeFetch('/api/behavior/agents-md', {
method: 'GET',
headers: { Accept: 'application/json' },
signal: abort.signal,
@@ -204,7 +205,7 @@ export const BehaviorPage: React.FC = () => {
setIsSaving(true);
try {
const content = normalizeAgentsMdContent(prompt);
const response = await fetch('/api/behavior/agents-md', {
const response = await runtimeFetch('/api/behavior/agents-md', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -2,6 +2,7 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { useMcpStore } from '@/stores/useMcpStore';
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { runtimeFetch } from '@/lib/runtime-fetch';
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
const value = params.get(key);
@@ -42,7 +43,7 @@ export const McpOAuthCallbackPage: React.FC = () => {
if (error) {
if (callbackStateKey) {
void fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
void runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('error');
setMessage(errorDescription ?? error);
@@ -57,7 +58,7 @@ export const McpOAuthCallbackPage: React.FC = () => {
let pendingContext = callbackContext;
if (!pendingContext && callbackStateKey) {
const response = await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
if (response.ok) {
const payload = await response.json().catch(() => null) as { name?: string; directory?: string | null } | null;
if (payload?.name?.trim()) {
@@ -75,13 +76,13 @@ export const McpOAuthCallbackPage: React.FC = () => {
await completeAuth(pendingContext.name, code, pendingContext.directory);
if (callbackStateKey) {
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('success');
setMessage('Authorization completed. You can close this tab and return to OpenChamber.');
} catch (authError) {
if (callbackStateKey) {
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('error');
setMessage(normalizeMcpAuthErrorMessage(authError, 'Failed to complete MCP authorization.'));
@@ -20,6 +20,8 @@ import {
} from './mcpImport';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
@@ -501,7 +503,7 @@ const buildMcpOAuthRedirectUri = (name?: string | null, directory?: string | nul
return null;
}
const url = new URL(MCP_OAUTH_CALLBACK_PATH, window.location.origin);
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
if (typeof name === 'string' && name.trim()) {
url.searchParams.set('server', name.trim());
}
@@ -516,7 +518,7 @@ const queuePendingMcpAuthContext = async (input: {
name: string;
directory?: string | null;
}): Promise<void> => {
const response = await fetch('/api/mcp/auth/pending', {
const response = await runtimeFetch('/api/mcp/auth/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -533,7 +535,7 @@ const queuePendingMcpAuthContext = async (input: {
};
const getPendingMcpAuthContext = async (stateKey: string): Promise<{ name: string; directory: string | null } | null> => {
const response = await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
if (!response.ok) {
return null;
}
@@ -554,7 +556,7 @@ const clearPendingMcpAuthContext = async (stateKey: string | null | undefined):
return;
}
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey.trim())}`, { method: 'DELETE' }).catch(() => undefined);
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey.trim())}`, { method: 'DELETE' }).catch(() => undefined);
};
const normalizeMcpAuthErrorMessage = (
@@ -10,6 +10,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { runtimeFetch } from '@/lib/runtime-fetch';
const getDisplayModel = (
storedModel: string | undefined
@@ -76,7 +77,7 @@ export const DefaultsSettings: React.FC = () => {
}
if (!data) {
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -131,7 +132,7 @@ export const DefaultsSettings: React.FC = () => {
try {
await updateDesktopSettings({ defaultModel: newValue ?? '', defaultVariant: '' });
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultModel: newValue }),
@@ -12,6 +12,8 @@ import {
setDesktopLaunchAtLogin,
} from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
export const DesktopNetworkSettings: React.FC = () => {
const { t } = useI18n();
@@ -37,7 +39,7 @@ export const DesktopNetworkSettings: React.FC = () => {
let cancelled = false;
void (async () => {
try {
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -123,7 +125,14 @@ export const DesktopNetworkSettings: React.FC = () => {
return null;
}
const parsed = Number(window.location.port);
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
const portSource = runtimeApiBaseUrl || window.location.href;
let parsed = 0;
try {
parsed = Number(new URL(portSource).port);
} catch {
parsed = Number(window.location.port);
}
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}, []);
const lanUrl = draftValue && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null;
@@ -165,7 +174,7 @@ export const DesktopNetworkSettings: React.FC = () => {
setError(null);
try {
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { Icon } from "@/components/icon/Icon";
type GitHubUser = {
@@ -81,7 +82,7 @@ export const GitHubSettings: React.FC = () => {
const payload = runtimeGitHub
? await runtimeGitHub.authStart()
: await (async () => {
const response = await fetch('/api/github/auth/start', {
const response = await runtimeFetch('/api/github/auth/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -114,7 +115,7 @@ export const GitHubSettings: React.FC = () => {
return runtimeGitHub.authComplete(deviceCode) as Promise<DeviceFlowCompleteResponse>;
}
const response = await fetch('/api/github/auth/complete', {
const response = await runtimeFetch('/api/github/auth/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -181,7 +182,7 @@ export const GitHubSettings: React.FC = () => {
if (runtimeGitHub) {
await runtimeGitHub.authDisconnect();
} else {
const response = await fetch('/api/github/auth', {
const response = await runtimeFetch('/api/github/auth', {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
@@ -206,7 +207,7 @@ export const GitHubSettings: React.FC = () => {
const payload = runtimeGitHub
? await runtimeGitHub.authActivate(accountId)
: await (async () => {
const response = await fetch('/api/github/auth/activate', {
const response = await runtimeFetch('/api/github/auth/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
export const GitSettings: React.FC = () => {
const { t } = useI18n();
@@ -63,7 +64,7 @@ export const GitSettings: React.FC = () => {
// 2. Fetch API (Web/server)
if (!data) {
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -15,8 +15,19 @@ import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import type { OpenChamberSection } from './types';
const useRuntimeEndpointEpoch = (): number => {
const [epoch, setEpoch] = React.useState(0);
React.useEffect(() => {
return subscribeRuntimeEndpointChanged(() => setEpoch((current) => current + 1));
}, []);
return epoch;
};
interface OpenChamberPageProps {
/** Which section to display. If undefined, shows all sections (mobile/legacy behavior) */
section?: OpenChamberSection;
@@ -24,8 +35,10 @@ interface OpenChamberPageProps {
export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) => {
const { isMobile } = useDeviceInfo();
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
const showAbout = isMobile && isWebRuntime();
const isVSCode = isVSCodeRuntime();
void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive();
// If no section specified, show all (mobile/legacy behavior)
@@ -135,6 +148,8 @@ const ChatSectionContent: React.FC = () => {
// Sessions section: Default model & agent, Session retention
const SessionsSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime();
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive();
return (
<div className="space-y-6">
@@ -1,4 +1,5 @@
import React from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -27,6 +28,7 @@ import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS,
import { useI18n, type Locale } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore';
import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getStoredMobileLayoutPreference, setStoredMobileLayoutPreference, type MobileLayoutPreference } from '@/lib/mobileLayoutPreference';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
@@ -129,6 +131,17 @@ const MOBILE_KEYBOARD_MODE_OPTIONS: Option<MobileKeyboardMode>[] = [
},
];
const MOBILE_LAYOUT_OPTIONS: Array<{ value: MobileLayoutPreference; labelKey: string }> = [
{
value: 'default',
labelKey: 'settings.openchamber.visual.option.mobileLayout.default',
},
{
value: 'new',
labelKey: 'settings.openchamber.visual.option.mobileLayout.new',
},
];
type PwaInstallNameWindow = Window & {
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
__OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape';
@@ -483,9 +496,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const isVSCode = isVSCodeRuntime();
const hasThemeSettings = shouldShow('theme') && !isVSCode;
const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart');
const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode;
const hasAppearanceSettings = isVSCode
? hasLocalizationSettings
: (shouldShow('theme') || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
: (shouldShow('theme') || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset');
const hasNavigationSettings = shouldShow('terminalQuickKeys') && !isMobile;
const hasBehaviorSettings = shouldShow('mermaidRendering')
@@ -509,6 +523,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode;
const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode;
const showMobileKeyboardModeSetting = shouldShow('mobileKeyboardMode') && isWebRuntime() && !isDesktopShell() && !isVSCode && supportsMobileKeyboardResizeContent();
const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState<MobileLayoutPreference>(() => getStoredMobileLayoutPreference());
const [pwaInstallName, setPwaInstallName] = React.useState('');
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
const selectedTimeFormatLabel = React.useMemo(() => {
@@ -528,6 +543,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
return option ? tUnsafe(option.labelKey) : undefined;
}, [mobileKeyboardMode, tUnsafe]);
const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => {
if (value === mobileLayoutPreference) {
return;
}
setMobileLayoutPreference(value);
setStoredMobileLayoutPreference(value);
window.location.reload();
}, [mobileLayoutPreference]);
const applyPwaInstallName = React.useCallback(async (value: string) => {
if (typeof window === 'undefined') {
return;
@@ -578,7 +603,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const loadPwaInstallName = async () => {
try {
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
@@ -656,6 +681,26 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
</div>
{showMobileLayoutSetting && (
<div className="flex min-w-0 flex-col gap-1.5 py-1.5">
<span className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.mobileLayout')}</span>
<div className="flex flex-wrap items-center gap-1">
{MOBILE_LAYOUT_OPTIONS.map((option) => (
<Button
key={option.value}
variant="chip"
size="xs"
aria-pressed={mobileLayoutPreference === option.value}
className="!font-normal"
onClick={() => handleMobileLayoutPreferenceChange(option.value)}
>
{tUnsafe(option.labelKey)}
</Button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
<div className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.lightTheme')}</span>
@@ -9,6 +9,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
export const OpenCodeCliSettings: React.FC = () => {
const { t } = useI18n();
@@ -22,7 +23,7 @@ export const OpenCodeCliSettings: React.FC = () => {
let cancelled = false;
void (async () => {
try {
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -1,6 +1,7 @@
import React from 'react';
import QRCode from 'qrcode';
import { toast } from '@/components/ui';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
@@ -12,6 +13,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
type TunnelState =
| 'checking'
@@ -364,7 +366,14 @@ export const TunnelSettings: React.FC = () => {
if (typeof window === 'undefined') {
return null;
}
const parsed = Number(window.location.port);
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
const portSource = runtimeApiBaseUrl || window.location.href;
let parsed = 0;
try {
parsed = Number(new URL(portSource).port);
} catch {
parsed = Number(window.location.port);
}
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
@@ -398,10 +407,10 @@ export const TunnelSettings: React.FC = () => {
const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => {
try {
const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([
fetch('/api/openchamber/tunnel/check', { signal }),
fetch('/api/openchamber/tunnel/status', { signal }),
fetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }),
fetch('/api/openchamber/tunnel/providers', { signal }),
runtimeFetch('/api/openchamber/tunnel/check', { signal }),
runtimeFetch('/api/openchamber/tunnel/status', { signal }),
runtimeFetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }),
runtimeFetch('/api/openchamber/tunnel/providers', { signal }),
]);
const checkData = await checkRes.json();
@@ -614,7 +623,7 @@ export const TunnelSettings: React.FC = () => {
let cancelled = false;
const refreshSessions = async () => {
try {
const statusRes = await fetch('/api/openchamber/tunnel/status');
const statusRes = await runtimeFetch('/api/openchamber/tunnel/status');
if (!statusRes.ok || cancelled) {
return;
}
@@ -818,7 +827,7 @@ export const TunnelSettings: React.FC = () => {
});
}
const res = await fetch('/api/openchamber/tunnel/start', {
const res = await runtimeFetch('/api/openchamber/tunnel/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -913,8 +922,8 @@ export const TunnelSettings: React.FC = () => {
setState('stopping');
try {
await fetch('/api/openchamber/tunnel/stop', { method: 'POST' });
const statusRes = await fetch('/api/openchamber/tunnel/status');
await runtimeFetch('/api/openchamber/tunnel/stop', { method: 'POST' });
const statusRes = await runtimeFetch('/api/openchamber/tunnel/status');
if (statusRes.ok) {
const statusData = (await statusRes.json()) as TunnelStatusResponse;
setSessionRecords(Array.isArray(statusData.activeSessions) ? statusData.activeSessions : []);
@@ -20,6 +20,7 @@ import { audioStreamService } from '@/lib/voice/audioStreamService';
import { wasmSttService, WASM_MODELS } from '@/lib/voice/wasmSttService';
import type { WasmModelStatus } from '@/lib/voice/wasmSttService';
import { cn } from '@/lib/utils';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useI18n } from '@/lib/i18n';
import { disposePreviewAudio } from './voicePreviewAudio';
const LANGUAGE_OPTIONS = [
@@ -278,7 +279,7 @@ export const VoiceSettings: React.FC = () => {
const checkOpenAIAvailability = async () => {
try {
const response = await fetch('/api/tts/status');
const response = await runtimeFetch('/api/tts/status');
const data = await response.json();
const hasServerKey = data.available;
const hasSettingsKey = openaiApiKey.trim().length > 0;
@@ -298,7 +299,7 @@ export const VoiceSettings: React.FC = () => {
return;
}
fetch('/api/tts/say/status')
runtimeFetch('/api/tts/say/status')
.then(res => res.json())
.then(data => {
setIsSayAvailable(data.available);
@@ -327,7 +328,7 @@ export const VoiceSettings: React.FC = () => {
setIsPreviewPlaying(true);
let audio: HTMLAudioElement | null = null;
try {
const response = await fetch('/api/tts/say/speak', {
const response = await runtimeFetch('/api/tts/say/speak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -381,7 +382,7 @@ export const VoiceSettings: React.FC = () => {
setIsOpenAIPreviewPlaying(true);
let audio: HTMLAudioElement | null = null;
try {
const response = await fetch('/api/tts/speak', {
const response = await runtimeFetch('/api/tts/speak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -441,7 +442,7 @@ export const VoiceSettings: React.FC = () => {
setIsCompatiblePreviewPlaying(true);
let audio: HTMLAudioElement | null = null;
try {
const response = await fetch('/api/tts/speak', {
const response = await runtimeFetch('/api/tts/speak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -6,7 +6,7 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
import { Icon } from "@/components/icon/Icon";
@@ -160,16 +160,8 @@ export const ProjectsPage: React.FC = () => {
const hasCustomIcon = selectedProject?.iconImage?.source === 'custom';
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
const hasRemovableImageIcon = effectiveHasImageIcon;
const iconPreviewUrl = !previewImageFailed
? (hasPendingUploadImageIcon
? pendingUploadIconPreviewUrl
: (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon
? getProjectIconImageUrl(selectedProject, {
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
})
: null))
: null;
const showStoredImagePreview = Boolean(selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon);
const showImagePreview = !previewImageFailed && (hasPendingUploadImageIcon || showStoredImagePreview);
const handleUploadIcon = React.useCallback((file: File | null) => {
if (!selectedProject || !file || isUploadingIcon) {
@@ -368,7 +360,7 @@ export const ProjectsPage: React.FC = () => {
);
})}
</div>
{effectiveHasImageIcon && iconPreviewUrl && (
{effectiveHasImageIcon && showImagePreview && (
<div className="mt-2 flex items-center gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
@@ -376,13 +368,25 @@ export const ProjectsPage: React.FC = () => {
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
<img
src={iconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
{hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? (
<img
src={pendingUploadIconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
) : selectedProject ? (
<ProjectIconImage
project={selectedProject}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
onError={() => setPreviewImageFailed(true)}
/>
) : null}
</span>
</span>
</div>
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
import { Icon } from "@/components/icon/Icon";
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { cn } from '@/lib/utils';
import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -18,7 +18,6 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
const { currentTheme } = useThemeSystem();
const [brokenIconIds, setBrokenIconIds] = React.useState<Set<string>>(new Set());
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
@@ -66,45 +65,32 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
{projects.map((project) => {
const selected = project.id === selectedId;
const iconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const imageFailureKey = `${project.id}:${project.iconImage?.updatedAt ?? 0}`;
const imageUrl = brokenIconIds.has(imageFailureKey)
? null
: getProjectIconImageUrl(project, {
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
});
const color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
const icon = imageUrl
? (
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img
src={imageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => {
setBrokenIconIds((prev) => {
if (prev.has(imageFailureKey)) {
return prev;
}
const next = new Set(prev);
next.add(imageFailureKey);
return next;
});
}}
/>
</span>
)
: iconName
const fallbackIcon = iconName
? (
<Icon name={iconName} className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
)
: (
<Icon name="folder" className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
);
const icon = project.iconImage
? (
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<ProjectIconImage
project={project}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
)
: fallbackIcon;
return (
<SettingsSidebarItem
@@ -21,6 +21,8 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import type { ModelMetadata } from '@/types';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client';
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
@@ -180,18 +182,12 @@ export const ProvidersPage: React.FC = () => {
const loadAuthMethods = async () => {
setAuthLoading(true);
try {
const response = await fetch('/api/provider/auth', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Auth methods request failed (${response.status})`);
const result = await opencodeClient.getSdkClient().provider.auth();
if (result.error) {
throw new Error(`provider.auth failed: ${String(result.error)}`);
}
const payload = await response.json().catch(() => ({}));
if (!isMounted) return;
setAuthMethodsByProvider(parseAuthPayload(payload));
setAuthMethodsByProvider(parseAuthPayload(result.data));
} catch (error) {
if (!isMounted) return;
console.error('Failed to load provider auth methods:', error);
@@ -217,18 +213,12 @@ export const ProvidersPage: React.FC = () => {
setAvailableLoading(true);
setAvailableError(null);
try {
const response = await fetch('/api/provider', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Provider list request failed (${response.status})`);
const result = await opencodeClient.getSdkClient().provider.list();
if (result.error) {
throw new Error(`provider.list failed: ${String(result.error)}`);
}
const payload = await response.json().catch(() => ({}));
if (!isMounted) return;
setAvailableProviders(parseProvidersPayload(payload));
setAvailableProviders(parseProvidersPayload(result.data));
} catch (error) {
if (!isMounted) return;
console.error('Failed to load available providers:', error);
@@ -292,7 +282,9 @@ export const ProvidersPage: React.FC = () => {
const loadSources = async () => {
try {
const response = await fetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
// not local auth/source-file provenance used by this settings UI.
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -337,16 +329,12 @@ export const ProvidersPage: React.FC = () => {
setAuthBusyKey(busyKey);
try {
const response = await fetch(`/api/auth/${encodeURIComponent(providerId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'api', key: apiKey }),
const result = await opencodeClient.getSdkClient().auth.set({
providerID: providerId,
auth: { type: 'api', key: apiKey },
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || t('settings.providers.page.toast.apiKeySaveFailed');
throw new Error(message);
if (result.error) {
throw new Error(t('settings.providers.page.toast.apiKeySaveFailed'));
}
toast.success(t('settings.providers.page.toast.apiKeySaved'));
@@ -366,20 +354,17 @@ export const ProvidersPage: React.FC = () => {
setAuthBusyKey(busyKey);
try {
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/authorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: methodIndex }),
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
providerID: providerId,
method: methodIndex,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || t('settings.providers.page.toast.oauthStartFailed');
throw new Error(message);
if (result.error) {
throw new Error(t('settings.providers.page.toast.oauthStartFailed'));
}
const payloadRecord = isRecord(payload) ? payload : {};
const dataRecord = isRecord(payloadRecord.data) ? payloadRecord.data : payloadRecord;
const payloadRecord: Record<string, unknown> = isRecord(result.data) ? result.data : {};
const nestedData = payloadRecord.data;
const dataRecord: Record<string, unknown> = isRecord(nestedData) ? nestedData : payloadRecord;
const urlCandidate =
(typeof dataRecord.url === 'string' && dataRecord.url) ||
(typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) ||
@@ -435,16 +420,13 @@ export const ProvidersPage: React.FC = () => {
requestBody.code = code;
}
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
const result = await opencodeClient.getSdkClient().provider.oauth.callback({
providerID: providerId,
method: requestBody.method,
code: requestBody.code,
});
const responsePayload = await response.json().catch(() => null);
if (!response.ok) {
const message = responsePayload?.error || t('settings.providers.page.toast.oauthCompleteFailed');
throw new Error(message);
if (result.error) {
throw new Error(t('settings.providers.page.toast.oauthCompleteFailed'));
}
toast.success(t('settings.providers.page.toast.oauthCompleted'));
@@ -485,15 +467,9 @@ export const ProvidersPage: React.FC = () => {
setAuthBusyKey(busyKey);
try {
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || t('settings.providers.page.toast.providerDisconnectFailed');
throw new Error(message);
const result = await opencodeClient.getSdkClient().auth.remove({ providerID: providerId });
if (result.error) {
throw new Error(t('settings.providers.page.toast.providerDisconnectFailed'));
}
toast.success(t('settings.providers.page.toast.providerDisconnected'));
@@ -9,6 +9,7 @@ import { SettingsProjectSelector } from '@/components/sections/shared/SettingsPr
import { Icon } from "@/components/icon/Icon";
import { opencodeClient } from '@/lib/opencode/client';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
const ADD_PROVIDER_ID = '__add_provider__';
@@ -61,7 +62,9 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
const tasks = providers.map(async (provider) => {
try {
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
// not local auth/source-file provenance used by this settings sidebar.
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -1,4 +1,5 @@
import React from 'react';
import QRCode from 'qrcode';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
@@ -27,6 +28,9 @@ import { Icon } from "@/components/icon/Icon";
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { RemoteClientRecord } from '@/lib/api/types';
import { buildClientConnectionPayload, encodeClientConnectionPayload, parseClientConnectionPayload } from '@/lib/connectionPayload';
import {
desktopSshLogsClear,
desktopSshLogs,
@@ -34,6 +38,16 @@ import {
type DesktopSshPortForward,
type DesktopSshPortForwardType,
} from '@/lib/desktopSsh';
import {
desktopHostsGet,
desktopHostsSet,
normalizeHostUrl,
redactSensitiveUrl,
resolveDesktopHostUrl,
type DesktopHost,
} from '@/lib/desktopHosts';
import { isDesktopShell } from '@/lib/desktop';
import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch';
const randomPort = (): number => {
return Math.floor(20000 + Math.random() * 30000);
@@ -241,9 +255,12 @@ const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => {
export const RemoteInstancesPage: React.FC = () => {
const { t } = useI18n();
const { clientAuth } = useRuntimeAPIs();
const showInstanceManagement = isDesktopShell();
const instances = useDesktopSshStore((state) => state.instances);
const statusesById = useDesktopSshStore((state) => state.statusesById);
const importCandidates = useDesktopSshStore((state) => state.importCandidates);
const isLoading = useDesktopSshStore((state) => state.isLoading);
const isImportsLoading = useDesktopSshStore((state) => state.isImportsLoading);
const isSaving = useDesktopSshStore((state) => state.isSaving);
const error = useDesktopSshStore((state) => state.error);
@@ -277,12 +294,276 @@ export const RemoteInstancesPage: React.FC = () => {
const [isPrimaryActionPending, setIsPrimaryActionPending] = React.useState(false);
const [isRetryPending, setIsRetryPending] = React.useState(false);
const [clockMs, setClockMs] = React.useState(() => Date.now());
const [directHosts, setDirectHosts] = React.useState<DesktopHost[]>([]);
const [directDefaultHostId, setDirectDefaultHostId] = React.useState<string | null>('local');
const [directLoading, setDirectLoading] = React.useState(false);
const [directSaving, setDirectSaving] = React.useState(false);
const [directLabel, setDirectLabel] = React.useState('');
const [directUrl, setDirectUrl] = React.useState('');
const [directToken, setDirectToken] = React.useState('');
const [directConnectLink, setDirectConnectLink] = React.useState('');
const [directError, setDirectError] = React.useState<string | null>(null);
const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false);
const [directImportDialogOpen, setDirectImportDialogOpen] = React.useState(false);
const [directEditingId, setDirectEditingId] = React.useState<string | null>(null);
const [directEditLabel, setDirectEditLabel] = React.useState('');
const [directEditUrl, setDirectEditUrl] = React.useState('');
const [directEditToken, setDirectEditToken] = React.useState('');
const [remoteClients, setRemoteClients] = React.useState<RemoteClientRecord[]>([]);
const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false);
const [remoteClientLabel, setRemoteClientLabel] = React.useState('');
const [createdRemoteClientToken, setCreatedRemoteClientToken] = React.useState<string | null>(null);
const [remoteClientError, setRemoteClientError] = React.useState<string | null>(null);
const [pairingUrl, setPairingUrl] = React.useState<string | null>(null);
const [pairingQrDataUrl, setPairingQrDataUrl] = React.useState<string | null>(null);
const revokedClientCount = React.useMemo(() => remoteClients.filter((client) => Boolean(client.revokedAt)).length, [remoteClients]);
const [sshAddDialogOpen, setSshAddDialogOpen] = React.useState(false);
const [sshCommandDraft, setSshCommandDraft] = React.useState('ssh user@example.com');
const [sshNameDraft, setSshNameDraft] = React.useState('');
React.useEffect(() => {
void load();
void loadImports();
}, [load, loadImports]);
const loadDirectHosts = React.useCallback(async () => {
setDirectLoading(true);
setDirectError(null);
try {
const config = await desktopHostsGet();
setDirectHosts(config.hosts || []);
setDirectDefaultHostId(config.defaultHostId || 'local');
} catch (err) {
setDirectError(err instanceof Error ? err.message : String(err));
} finally {
setDirectLoading(false);
}
}, []);
React.useEffect(() => {
void loadDirectHosts();
}, [loadDirectHosts]);
const persistDirectHosts = React.useCallback(async (hosts: DesktopHost[], defaultHostId: string | null = directDefaultHostId) => {
setDirectSaving(true);
setDirectError(null);
try {
await desktopHostsSet({ hosts, defaultHostId, initialHostChoiceCompleted: true });
setDirectHosts(hosts);
setDirectDefaultHostId(defaultHostId);
} catch (err) {
setDirectError(err instanceof Error ? err.message : String(err));
} finally {
setDirectSaving(false);
}
}, [directDefaultHostId]);
const handleAddDirectHost = React.useCallback(async () => {
const resolved = resolveDesktopHostUrl(directUrl);
if (!resolved) {
setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const url = resolved.persistedUrl;
const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const host: DesktopHost = {
id,
label: directLabel.trim() || redactSensitiveUrl(url),
url,
apiUrl: url,
...(directToken.trim() ? { clientToken: directToken.trim() } : {}),
};
await persistDirectHosts([host, ...directHosts], directDefaultHostId);
setDirectLabel('');
setDirectUrl('');
setDirectToken('');
setDirectAddDialogOpen(false);
if (resolved.redeemUrl) {
navigateToUrl(resolved.redeemUrl);
}
}, [directDefaultHostId, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]);
const importDirectConnectLink = React.useCallback(async () => {
const payload = parseClientConnectionPayload(directConnectLink);
if (!payload) {
setDirectError(t('settings.remoteInstances.direct.error.invalidConnectLink'));
return;
}
const url = normalizeHostUrl(payload.serverUrl);
if (!url) {
setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const existing = directHosts.find((host) => normalizeHostUrl(host.apiUrl || host.url) === url);
if (existing) {
const nextHosts = directHosts.map((host) => host.id === existing.id
? { ...host, label: payload.label || host.label, url, apiUrl: url, clientToken: payload.token }
: host);
await persistDirectHosts(nextHosts, directDefaultHostId);
} else {
const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await persistDirectHosts([{ id, label: payload.label || redactSensitiveUrl(url), url, apiUrl: url, clientToken: payload.token }, ...directHosts], directDefaultHostId);
}
setDirectConnectLink('');
setDirectError(null);
setDirectImportDialogOpen(false);
}, [directConnectLink, directDefaultHostId, directHosts, persistDirectHosts, t]);
const handleRemoveDirectHost = React.useCallback(async (id: string) => {
const nextHosts = directHosts.filter((host) => host.id !== id);
const nextDefault = directDefaultHostId === id ? 'local' : directDefaultHostId;
await persistDirectHosts(nextHosts, nextDefault);
if (directEditingId === id) {
setDirectEditingId(null);
}
}, [directDefaultHostId, directEditingId, directHosts, persistDirectHosts]);
const beginEditDirectHost = React.useCallback((host: DesktopHost) => {
setDirectEditingId(host.id);
setDirectEditLabel(host.label);
setDirectEditUrl(host.apiUrl || host.url);
setDirectEditToken(host.clientToken || '');
setDirectError(null);
}, []);
const saveDirectHostEdit = React.useCallback(async () => {
if (!directEditingId) return;
const resolved = resolveDesktopHostUrl(directEditUrl);
if (!resolved) {
setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const url = resolved.persistedUrl;
const nextHosts = directHosts.map((host) => host.id === directEditingId
? {
...host,
label: directEditLabel.trim() || redactSensitiveUrl(url),
url,
apiUrl: url,
clientToken: directEditToken.trim() || undefined,
}
: host);
await persistDirectHosts(nextHosts, directDefaultHostId);
setDirectEditingId(null);
if (resolved.redeemUrl) {
navigateToUrl(resolved.redeemUrl);
}
}, [directDefaultHostId, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]);
const createSshInstanceFromDialog = React.useCallback(async () => {
const command = sshCommandDraft.trim();
if (!command) {
toast.error(t('settings.remoteInstances.page.toast.sshCommandRequired'));
return;
}
const id = `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
await createFromCommand(id, command, sshNameDraft.trim() || t('settings.remoteInstances.sidebar.newSshInstanceName'));
setSelectedId(id);
setSshAddDialogOpen(false);
setSshCommandDraft('ssh user@example.com');
setSshNameDraft('');
toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
} catch (error) {
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
description: error instanceof Error ? error.message : String(error),
});
}
}, [createFromCommand, setSelectedId, sshCommandDraft, sshNameDraft, t]);
const setDefaultDirectHost = React.useCallback(async (id: string) => {
await persistDirectHosts(directHosts, id);
}, [directHosts, persistDirectHosts]);
const loadRemoteClients = React.useCallback(async () => {
if (!clientAuth) return;
setRemoteClientsLoading(true);
setRemoteClientError(null);
try {
setRemoteClients(await clientAuth.listClients());
} catch (err) {
setRemoteClientError(err instanceof Error ? err.message : String(err));
} finally {
setRemoteClientsLoading(false);
}
}, [clientAuth]);
React.useEffect(() => {
void loadRemoteClients();
}, [loadRemoteClients]);
const createRemoteClient = React.useCallback(async () => {
if (!clientAuth) return;
setRemoteClientError(null);
try {
const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || undefined });
setCreatedRemoteClientToken(result.token);
setRemoteClientLabel('');
await loadRemoteClients();
} catch (err) {
setRemoteClientError(err instanceof Error ? err.message : String(err));
}
}, [clientAuth, loadRemoteClients, remoteClientLabel]);
const createPairingLink = React.useCallback(async () => {
if (!clientAuth) return;
setRemoteClientError(null);
try {
const serverUrl = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin;
const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || 'Paired client' });
const payload = buildClientConnectionPayload({ serverUrl, token: result.token, label: remoteClientLabel || 'OpenChamber' });
const encoded = encodeClientConnectionPayload(payload);
setCreatedRemoteClientToken(result.token);
setPairingUrl(encoded);
setPairingQrDataUrl(await QRCode.toDataURL(encoded, { width: 192, margin: 1 }));
setRemoteClientLabel('');
await loadRemoteClients();
} catch (err) {
setRemoteClientError(err instanceof Error ? err.message : String(err));
}
}, [clientAuth, loadRemoteClients, remoteClientLabel]);
const revokeRemoteClient = React.useCallback(async (client: RemoteClientRecord) => {
if (!clientAuth) return;
const isLocalDesktopClient = client.clientKind === 'desktop-local';
setRemoteClientError(null);
try {
await clientAuth.revokeClient(client.id);
if (isLocalDesktopClient && isDesktopShell()) {
const config = await desktopHostsGet();
await desktopHostsSet({
hosts: config.hosts,
defaultHostId: config.defaultHostId,
initialHostChoiceCompleted: config.initialHostChoiceCompleted,
localClientToken: null,
});
setRemoteClients((clients) => clients.map((entry) => entry.id === client.id
? { ...entry, revokedAt: new Date().toISOString() }
: entry));
switchRuntimeEndpoint({ apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: null, runtimeKey: 'local' });
return;
}
await loadRemoteClients();
} catch (err) {
setRemoteClientError(err instanceof Error ? err.message : String(err));
}
}, [clientAuth, loadRemoteClients]);
const purgeRevokedRemoteClients = React.useCallback(async () => {
if (!clientAuth) return;
setRemoteClientError(null);
try {
await clientAuth.purgeRevokedClients();
await loadRemoteClients();
} catch (err) {
setRemoteClientError(err instanceof Error ? err.message : String(err));
}
}, [clientAuth, loadRemoteClients]);
React.useEffect(() => {
setDraft(selectedInstance);
}, [selectedInstance]);
@@ -674,17 +955,271 @@ export const RemoteInstancesPage: React.FC = () => {
if (!draft) {
return (
<SettingsPageLayout>
<div className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.description')}</p>
{clientAuth ? (
<div className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.clientAuth.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.description')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input className="h-8" value={remoteClientLabel} onChange={(event) => setRemoteClientLabel(event.target.value)} placeholder={t('settings.remoteInstances.clientAuth.field.labelPlaceholder')} />
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void createRemoteClient()}>
{t('settings.remoteInstances.clientAuth.actions.create')}
</Button>
<Button type="button" size="xs" className="!font-normal" onClick={() => void createPairingLink()}>
{t('settings.remoteInstances.clientAuth.actions.pair')}
</Button>
</div>
{pairingUrl ? (
<div className="flex flex-col gap-3 rounded-md border border-[var(--interactive-border)] p-2 sm:flex-row">
{pairingQrDataUrl ? <img src={pairingQrDataUrl} alt={t('settings.remoteInstances.clientAuth.qrAlt')} className="size-48 self-start" /> : null}
<div className="min-w-0 flex-1 space-y-2">
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.pairingUrl')}</p>
<code className="block select-all break-all typography-code text-foreground">{pairingUrl}</code>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void copyTextToClipboard(pairingUrl)}>
<Icon name="file-copy" className="h-3.5 w-3.5" />
{t('settings.common.actions.copyAll')}
</Button>
</div>
</div>
) : null}
{createdRemoteClientToken ? (
<div className="space-y-1 rounded-md border border-[var(--interactive-border)] p-2">
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.createdToken')}</p>
<code className="block select-all break-all typography-code text-foreground">{createdRemoteClientToken}</code>
</div>
) : null}
<div className="space-y-1">
{revokedClientCount > 0 ? (
<div className="flex justify-end">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void purgeRevokedRemoteClients()}>
{t('settings.remoteInstances.clientAuth.actions.clearRevoked')}
</Button>
</div>
) : null}
{remoteClientsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.state.loading')}</p>
) : remoteClients.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.state.empty')}</p>
) : remoteClients.map((client) => {
const isLocalDesktopClient = client.clientKind === 'desktop-local';
return (
<div key={client.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="typography-ui-label text-foreground truncate">{client.label}</p>
{isLocalDesktopClient ? (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{t('settings.remoteInstances.clientAuth.state.thisDevice')}
</span>
) : null}
</div>
<p className="typography-micro text-muted-foreground truncate">{client.revokedAt ? t('settings.remoteInstances.clientAuth.state.revoked') : client.lastUsedAt ? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt }) : t('settings.remoteInstances.clientAuth.neverUsed')}</p>
</div>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void revokeRemoteClient(client)} disabled={Boolean(client.revokedAt)}>
{t('settings.remoteInstances.clientAuth.actions.revoke')}
</Button>
</div>
);
})}
</div>
{remoteClientError ? <p className="typography-meta text-[var(--status-error)]">{remoteClientError}</p> : null}
</section>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.empty.selectInstance')}</p>
</section>
</div>
) : null}
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.description')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-4">
<div className="flex items-center justify-between gap-2">
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.direct.note')}</p>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(true)} disabled={directSaving}>
{t('settings.remoteInstances.direct.import.action')}
</Button>
<Button type="button" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(true)} disabled={directSaving}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.direct.actions.add')}
</Button>
</div>
</div>
<div className="space-y-1">
{directLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.loading')}</p>
) : directHosts.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.empty')}</p>
) : directHosts.map((host) => (
<div key={host.id} className="py-1.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(host.label)}</p>
{directDefaultHostId === host.id ? <span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.header.default')}</span> : null}
</div>
<p className="typography-micro text-muted-foreground font-mono truncate">{redactSensitiveUrl(host.apiUrl || host.url)}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void setDefaultDirectHost(host.id)} disabled={directSaving || directDefaultHostId === host.id} aria-label={t('desktopHostSwitcher.actions.setAsDefaultAria')}>
{directDefaultHostId === host.id ? <Icon name="star-fill" className="h-3.5 w-3.5" /> : <Icon name="star" className="h-3.5 w-3.5" />}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => beginEditDirectHost(host)} disabled={directSaving}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void handleRemoveDirectHost(host.id)} disabled={directSaving}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
</div>
</div>
))}
</div>
{directError ? <p className="typography-meta text-[var(--status-error)]">{directError}</p> : null}
</section>
</div> : null}
{showInstanceManagement ? <Dialog open={directAddDialogOpen} onOpenChange={setDirectAddDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('settings.remoteInstances.direct.actions.add')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.direct.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void handleAddDirectHost(); }}>
<Input className="h-8" value={directLabel} onChange={(event) => setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directUrl} onChange={(event) => setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directToken} onChange={(event) => setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving || !directUrl.trim()}>{t('settings.remoteInstances.direct.actions.add')}</Button>
</div>
</form>
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <Dialog open={Boolean(directEditingId)} onOpenChange={(open) => { if (!open) setDirectEditingId(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('desktopHostSwitcher.actions.edit')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.direct.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void saveDirectHostEdit(); }}>
<Input className="h-8" value={directEditLabel} onChange={(event) => setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directEditUrl} onChange={(event) => setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directEditToken} onChange={(event) => setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectEditingId(null)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving}>{t('settings.common.actions.saveChanges')}</Button>
</div>
</form>
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <Dialog open={directImportDialogOpen} onOpenChange={setDirectImportDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('settings.remoteInstances.direct.import.action')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.direct.import.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void importDirectConnectLink(); }}>
<Input className="h-8" value={directConnectLink} onChange={(event) => setDirectConnectLink(event.target.value)} placeholder={t('settings.remoteInstances.direct.import.placeholder')} disabled={directSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving || !directConnectLink.trim()}>{t('settings.remoteInstances.direct.import.action')}</Button>
</div>
</form>
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.sidebar.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</p>
</div>
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
</Button>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-1">
{isLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : instances.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
) : instances.map((instance) => {
const instanceStatus = statusesById[instance.id];
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
const phase = instanceStatus?.phase;
const ready = phase === 'ready';
return (
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p>
</div>
<p className="typography-micro text-muted-foreground truncate">
{t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const op = ready ? disconnect(instance.id) : connect(instance.id);
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
if (!ok) return;
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
</div>
);
})}
</section>
</div> : null}
{showInstanceManagement ? <Dialog open={sshAddDialogOpen} onOpenChange={setSshAddDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('settings.remoteInstances.sidebar.actions.addSshInstance')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.page.section.instanceDescription')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
</div>
</form>
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
</div>
@@ -694,15 +1229,15 @@ export const RemoteInstancesPage: React.FC = () => {
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
) : (
<div className="space-y-2">
<div>
{importCandidates.map((candidate) => (
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 rounded-md border border-[var(--interactive-border)] px-3 py-2">
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-3 last:border-b-0">
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">
<div className="typography-ui-label font-medium text-foreground truncate">
{candidate.host}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
</div>
<div className="typography-micro text-muted-foreground">{candidate.source} config</div>
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
@@ -711,14 +1246,14 @@ export const RemoteInstancesPage: React.FC = () => {
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.remoteInstances.page.actions.create')}
{t('settings.common.actions.import')}
</Button>
</div>
))}
</div>
)}
</section>
</div>
</div> : null}
<Dialog
open={Boolean(patternHost)}
@@ -767,7 +1302,8 @@ export const RemoteInstancesPage: React.FC = () => {
const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id;
return (
<SettingsPageLayout>
<Dialog open={Boolean(draft)} onOpenChange={(open) => { if (!open) setSelectedId(null); }}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-auto">
<div className="mb-6 px-1">
<h2 className="typography-ui-header font-semibold text-foreground truncate">{instanceTitle}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
@@ -1466,46 +2002,7 @@ export const RemoteInstancesPage: React.FC = () => {
</section>
</div>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
</div>
<section className="px-2 pb-2 pt-0">
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneAvailable')}</p>
) : (
<div>
{importCandidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.source}:${candidate.host}`}
className={`flex items-center justify-between gap-2 px-1 py-2 ${index > 0 ? 'border-t border-[var(--surface-subtle)]' : ''}`}
>
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">
{candidate.host}
{candidate.pattern ? ' (pattern)' : ''}
</div>
<div className="typography-micro text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.common.actions.import')}
</Button>
</div>
))}
</div>
)}
</section>
</div>
<div className="sticky bottom-0 z-10 -mx-3 sm:-mx-6 bg-[var(--surface-background)] border-t border-[var(--interactive-border)] px-3 sm:px-6 py-3">
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
<div className="flex items-center gap-2">
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
{t('settings.common.actions.saveChanges')}
@@ -1617,6 +2114,7 @@ export const RemoteInstancesPage: React.FC = () => {
</form>
</DialogContent>
</Dialog>
</SettingsPageLayout>
</DialogContent>
</Dialog>
);
};
@@ -20,6 +20,8 @@ const makeId = (): string => {
return `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const DIRECT_INSTANCES_ID = '__direct_instances__';
const randomPort = (): number => {
return Math.floor(20000 + Math.random() * 30000);
};
@@ -76,6 +78,9 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
React.useEffect(() => {
if (isLoading) return;
if (selectedId === DIRECT_INSTANCES_ID) {
return;
}
if (instances.length === 0) {
if (selectedId !== null) {
setSelectedId(null);
@@ -130,7 +135,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
}, [connect, t, upsertInstance]);
return (
<SettingsSidebarLayout
<SettingsSidebarLayout
variant="background"
header={
<div className="border-b px-3 pt-4 pb-3">
@@ -151,6 +156,16 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
</div>
}
>
<SettingsSidebarItem
title={t('settings.remoteInstances.direct.sidebarTitle')}
metadata={t('settings.remoteInstances.direct.sidebarDescription')}
selected={selectedId === DIRECT_INSTANCES_ID || (!selectedId && instances.length === 0)}
onSelect={() => {
setSelectedId(DIRECT_INSTANCES_ID);
onItemSelect?.();
}}
icon={<Icon name="global" className="h-4 w-4 text-muted-foreground" />}
/>
{instances.map((instance) => {
const status = statusesById[instance.id];
const selected = instance.id === selectedId;
@@ -1,5 +1,6 @@
import React from 'react';
import { toast } from '@/components/ui';
import { runtimeFetch } from '@/lib/runtime-fetch';
import {
Dialog,
@@ -60,7 +61,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
return (result?.settings || {}) as DesktopSettings;
}
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -1,4 +1,5 @@
import React from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -50,7 +51,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
return (result?.settings || {}) as DesktopSettings;
}
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});

Some files were not shown because too many files have changed in this diff Show More