From 2031e3b4a8f66f87a151e3468db5c41b24fe8582 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 2 Jun 2026 00:43:05 +0300 Subject: [PATCH] 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. --- .../skills/clack-cli-patterns/SKILL.md | 0 .../skills/drag-to-reorder/SKILL.md | 0 .../skills/locale-ui-patterns/SKILL.md | 0 .../skills/settings-ui-patterns/SKILL.md | 0 .../skills/theme-system/SKILL.md | 0 .../theme-system/references/adding-themes.md | 0 .agents/skills/ui-api-decoupling/SKILL.md | 306 +++ AGENTS.md | 121 +- README.md | 29 +- fix-deprecation.js | 48 +- package.json | 3 +- packages/docs/content/docs/environment.mdx | 4 + packages/docs/content/docs/es/environment.mdx | 4 + .../docs/content/docs/es/opencode-server.mdx | 16 + .../docs/content/docs/es/remote-instances.mdx | 12 + packages/docs/content/docs/es/tunnels.mdx | 3 + packages/docs/content/docs/ko/environment.mdx | 4 + .../docs/content/docs/ko/opencode-server.mdx | 16 + .../docs/content/docs/ko/remote-instances.mdx | 12 + packages/docs/content/docs/ko/tunnels.mdx | 3 + .../docs/content/docs/opencode-server.mdx | 16 + packages/docs/content/docs/pl/environment.mdx | 4 + .../docs/content/docs/pl/opencode-server.mdx | 16 + .../docs/content/docs/pl/remote-instances.mdx | 12 + packages/docs/content/docs/pl/tunnels.mdx | 3 + .../docs/content/docs/pt-br/environment.mdx | 4 + .../content/docs/pt-br/opencode-server.mdx | 16 + .../content/docs/pt-br/remote-instances.mdx | 12 + packages/docs/content/docs/pt-br/tunnels.mdx | 3 + .../docs/content/docs/remote-instances.mdx | 12 + packages/docs/content/docs/tunnels.mdx | 3 + packages/docs/content/docs/uk/environment.mdx | 4 + .../docs/content/docs/uk/opencode-server.mdx | 16 + .../docs/content/docs/uk/remote-instances.mdx | 12 + packages/docs/content/docs/uk/tunnels.mdx | 2 + .../docs/content/docs/zh-cn/environment.mdx | 4 + .../content/docs/zh-cn/opencode-server.mdx | 16 + .../content/docs/zh-cn/remote-instances.mdx | 12 + packages/docs/content/docs/zh-cn/tunnels.mdx | 2 + packages/electron/main.mjs | 642 +++++- packages/electron/preload.mjs | 24 +- packages/electron/scripts/electron-dev.mjs | 55 +- packages/ui/src/App.tsx | 35 +- packages/ui/src/apps/ElectronMiniChatApp.tsx | 9 +- packages/ui/src/apps/MobileApp.tsx | 468 ++++ packages/ui/src/apps/MobileChangesSurface.tsx | 617 +++++ packages/ui/src/apps/MobileFilesSurface.tsx | 534 +++++ packages/ui/src/apps/MobileSessionsSheet.tsx | 1162 ++++++++++ packages/ui/src/apps/MobileSurfaceShell.tsx | 250 ++ packages/ui/src/apps/VSCodeApp.tsx | 3 +- packages/ui/src/apps/mobileAppContext.tsx | 38 + packages/ui/src/apps/renderMobileApp.tsx | 61 + .../src/components/auth/SessionAuthGate.tsx | 172 +- .../ui/src/components/chat/ChatContainer.tsx | 21 - packages/ui/src/components/chat/ChatInput.tsx | 75 +- .../ui/src/components/chat/FileAttachment.tsx | 13 +- .../components/chat/MarkdownRendererImpl.tsx | 3 +- .../ui/src/components/chat/MessageList.tsx | 25 - .../chat/MobileSessionStatusBar.tsx | 2015 +++-------------- .../src/components/chat/PendingChangesBar.tsx | 12 + .../hooks/useChatTimelineController.test.ts | 41 + .../chat/hooks/useChatTimelineController.ts | 89 +- .../components/chat/message/MessageBody.tsx | 15 +- .../chat/message/ToolOutputDialog.tsx | 3 +- .../desktop/DesktopHostSwitcher.tsx | 426 ++-- .../ui/src/components/layout/ContextPanel.tsx | 168 +- packages/ui/src/components/layout/Header.tsx | 211 +- .../ui/src/components/layout/MainLayout.tsx | 8 +- .../components/layout/ProjectEditDialog.tsx | 45 +- .../multirun/MultiRunFusionDialog.tsx | 29 +- .../components/multirun/MultiRunLauncher.tsx | 32 +- .../components/onboarding/ChooserScreen.tsx | 7 +- .../onboarding/DesktopConnectionRecovery.tsx | 4 +- .../onboarding/LocalSetupScreen.tsx | 7 +- .../components/onboarding/RecoveryScreen.tsx | 3 +- .../onboarding/RemoteConnectionForm.tsx | 45 +- .../onboarding/desktopRecoveryConfig.test.ts | 9 + .../onboarding/desktopRecoveryConfig.ts | 22 + .../onboarding/desktopRecoveryRouting.test.ts | 4 + .../onboarding/desktopRecoveryRouting.ts | 1 + .../sections/behavior/BehaviorPage.tsx | 9 +- .../sections/mcp/McpOAuthCallbackPage.tsx | 9 +- .../src/components/sections/mcp/McpPage.tsx | 10 +- .../sections/openchamber/DefaultsSettings.tsx | 5 +- .../openchamber/DesktopNetworkSettings.tsx | 15 +- .../sections/openchamber/GitHubSettings.tsx | 9 +- .../sections/openchamber/GitSettings.tsx | 3 +- .../sections/openchamber/OpenChamberPage.tsx | 15 + .../openchamber/OpenChamberVisualSettings.tsx | 49 +- .../openchamber/OpenCodeCliSettings.tsx | 3 +- .../sections/openchamber/TunnelSettings.tsx | 27 +- .../sections/openchamber/VoiceSettings.tsx | 11 +- .../sections/projects/ProjectsPage.tsx | 42 +- .../sections/projects/ProjectsSidebar.tsx | 54 +- .../sections/providers/ProvidersPage.tsx | 94 +- .../sections/providers/ProvidersSidebar.tsx | 5 +- .../remote-instances/RemoteInstancesPage.tsx | 612 ++++- .../RemoteInstancesSidebar.tsx | 17 +- .../skills/catalog/AddCatalogDialog.tsx | 3 +- .../skills/catalog/SkillsCatalogPage.tsx | 3 +- .../session/DirectoryExplorerDialog.tsx | 3 +- .../src/components/session/DirectoryTree.tsx | 3 +- .../session/GitHubIssuePickerDialog.tsx | 7 +- .../components/session/NewWorktreeDialog.tsx | 4 + .../session/ProjectNotesTodoPanel.tsx | 5 +- .../session/ScheduledTasksDialog.tsx | 32 +- .../session/sidebar/SessionNodeItem.tsx | 8 +- .../session/sidebar/sortableItems.tsx | 31 +- packages/ui/src/components/ui/AboutDialog.tsx | 5 +- .../ui/src/components/ui/UpdateDialog.tsx | 7 +- .../components/update/OpenCodeUpdateToast.tsx | 5 +- .../ui/src/components/views/FilesView.tsx | 42 +- .../src/components/views/PierreDiffViewer.tsx | 17 +- packages/ui/src/components/views/PlanView.tsx | 7 +- .../ui/src/components/views/SettingsView.tsx | 33 +- .../agent-manager/AgentManagerEmptyState.tsx | 10 +- .../views/agent-manager/AgentManagerView.tsx | 9 +- .../src/components/views/git/ChangesPanel.tsx | 7 +- .../ui/src/contexts/ThemeSystemContext.tsx | 3 +- .../ui/src/contexts/runtimeAPIRegistry.ts | 13 +- packages/ui/src/hooks/useChatAutoFollow.ts | 6 +- packages/ui/src/hooks/useSayTTS.ts | 5 +- packages/ui/src/hooks/useServerTTS.ts | 5 +- .../ui/src/hooks/useSessionAutoCleanup.ts | 6 +- .../ui/src/hooks/useWebNotificationStream.ts | 3 +- packages/ui/src/hooks/useWindowTitle.ts | 20 +- packages/ui/src/lib/api/types.ts | 39 + packages/ui/src/lib/connectionPayload.ts | 59 + packages/ui/src/lib/contextFileOpenGuard.ts | 3 +- packages/ui/src/lib/debug.ts | 34 +- packages/ui/src/lib/desktop.ts | 43 +- packages/ui/src/lib/desktopBoot.test.ts | 14 + packages/ui/src/lib/desktopBoot.ts | 10 +- packages/ui/src/lib/desktopHosts.test.ts | 34 + packages/ui/src/lib/desktopHosts.ts | 90 +- packages/ui/src/lib/detectDevServer.ts | 35 +- packages/ui/src/lib/execCommands.ts | 9 +- packages/ui/src/lib/exportSession.ts | 11 +- packages/ui/src/lib/gitApi.ts | 14 +- packages/ui/src/lib/gitApiHttp.ts | 156 +- .../ui/src/lib/i18n/messages/en.settings.ts | 90 +- packages/ui/src/lib/i18n/messages/en.ts | 101 + .../ui/src/lib/i18n/messages/es.settings.ts | 88 +- packages/ui/src/lib/i18n/messages/es.ts | 101 + .../ui/src/lib/i18n/messages/ko.settings.ts | 88 +- packages/ui/src/lib/i18n/messages/ko.ts | 101 + .../ui/src/lib/i18n/messages/pl.settings.ts | 88 +- packages/ui/src/lib/i18n/messages/pl.ts | 101 + .../src/lib/i18n/messages/pt-BR.settings.ts | 88 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 101 + .../ui/src/lib/i18n/messages/uk.settings.ts | 88 +- packages/ui/src/lib/i18n/messages/uk.ts | 101 + .../src/lib/i18n/messages/zh-CN.settings.ts | 88 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 101 + .../src/lib/i18n/messages/zh-TW.settings.ts | 34 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 101 + packages/ui/src/lib/magicPrompts.ts | 10 +- packages/ui/src/lib/mobileLayoutPreference.ts | 32 + packages/ui/src/lib/openCodeStatus.ts | 20 +- packages/ui/src/lib/openchamberConfig.ts | 15 +- packages/ui/src/lib/openchamberEvents.ts | 22 +- packages/ui/src/lib/opencode/client.ts | 489 ++-- packages/ui/src/lib/passkeys.ts | 22 +- packages/ui/src/lib/persistence.ts | 7 +- .../ui/src/lib/preview/screenshot-capture.ts | 5 +- packages/ui/src/lib/projectMeta.ts | 186 +- packages/ui/src/lib/responseStyle.ts | 4 +- packages/ui/src/lib/runtime-auth.test.ts | 63 + packages/ui/src/lib/runtime-auth.ts | 167 ++ packages/ui/src/lib/runtime-fetch.test.ts | 289 +++ packages/ui/src/lib/runtime-fetch.ts | 198 ++ packages/ui/src/lib/runtime-switch.ts | 103 + packages/ui/src/lib/runtime-url.test.ts | 98 + packages/ui/src/lib/runtime-url.ts | 140 ++ packages/ui/src/lib/scheduledTasksApi.ts | 10 +- .../ui/src/lib/server-compatibility.test.ts | 53 + packages/ui/src/lib/server-compatibility.ts | 159 ++ packages/ui/src/lib/settings/metadata.ts | 4 +- packages/ui/src/lib/terminalApi.ts | 35 +- packages/ui/src/lib/terminalPreview.ts | 4 +- .../ui/src/lib/voice/audioStreamService.ts | 4 +- packages/ui/src/lib/voice/summarize.ts | 15 +- packages/ui/src/lib/worktreeSessionCreator.ts | 6 +- .../ui/src/lib/worktrees/worktreeBootstrap.ts | 10 +- packages/ui/src/stores/fileStore.ts | 3 +- packages/ui/src/stores/permissionStore.ts | 5 +- packages/ui/src/stores/useAgentsStore.ts | 11 +- .../ui/src/stores/useCommandsStore.test.ts | 93 + packages/ui/src/stores/useCommandsStore.ts | 11 +- packages/ui/src/stores/useConfigStore.ts | 7 +- packages/ui/src/stores/useGitHubAuthStore.ts | 3 +- .../ui/src/stores/useGitIdentitiesStore.ts | 3 +- packages/ui/src/stores/useMcpConfigStore.ts | 9 +- .../ui/src/stores/usePluginsStore.test.ts | 11 + packages/ui/src/stores/usePluginsStore.ts | 19 +- packages/ui/src/stores/useProjectsStore.ts | 67 +- packages/ui/src/stores/useQuotaStore.ts | 5 +- .../ui/src/stores/useSessionFoldersStore.ts | 5 +- .../ui/src/stores/useSkillsCatalogStore.ts | 13 +- packages/ui/src/stores/useSkillsStore.ts | 17 +- packages/ui/src/stores/useSnippetsStore.ts | 11 +- packages/ui/src/stores/useUIStore.ts | 32 + packages/ui/src/stores/useUpdateStore.ts | 36 +- .../sync/__tests__/materialization.test.ts | 16 + .../__tests__/session-prefetch-cache.test.ts | 9 + packages/ui/src/sync/bootstrap.ts | 3 +- packages/ui/src/sync/event-pipeline.ts | 36 +- packages/ui/src/sync/materialization.ts | 5 +- packages/ui/src/sync/runtime-live-memory.ts | 50 + packages/ui/src/sync/session-actions.test.ts | 129 +- packages/ui/src/sync/session-actions.ts | 335 +-- packages/ui/src/sync/session-navigation.ts | 11 + .../ui/src/sync/session-prefetch-cache.ts | 17 +- packages/ui/src/sync/session-ui-store.test.js | 47 +- packages/ui/src/sync/session-ui-store.ts | 273 ++- packages/ui/src/sync/streaming.ts | 7 + packages/ui/src/sync/submit.ts | 56 +- packages/ui/src/sync/sync-context.tsx | 198 +- packages/ui/src/sync/use-sync.ts | 43 +- packages/ui/src/sync/viewport-store.ts | 13 +- packages/ui/src/types/zumer-snapdom.d.ts | 22 + .../vscode/src/bridge-config-runtime.test.js | 244 ++ packages/vscode/src/bridge-config-runtime.ts | 181 ++ .../src/bridge-git-special-runtime.test.js | 116 + .../vscode/src/bridge-git-special-runtime.ts | 154 +- .../src/bridge-localfs-proxy-runtime.ts | 16 +- .../vscode/src/bridge-proxy-runtime.test.ts | 59 + packages/vscode/src/bridge-proxy-runtime.ts | 31 +- packages/vscode/src/opencodeConfig.ts | 744 ++++++ packages/vscode/webview/api/bridge.test.ts | 44 + packages/vscode/webview/api/bridge.ts | 45 +- packages/vscode/webview/api/files.ts | 44 +- packages/vscode/webview/api/settings.ts | 55 +- packages/vscode/webview/api/tools.ts | 11 +- packages/vscode/webview/api/vscode.ts | 14 +- packages/vscode/webview/main.tsx | 425 ++-- .../webview/requestBodyTransport.test.ts | 55 + .../vscode/webview/requestBodyTransport.ts | 81 + packages/web/README.md | 38 + packages/web/bin/cli.js | 294 ++- packages/web/bin/cli.test.js | 62 + packages/web/mobile.html | 12 + packages/web/server/index.js | 29 + .../server/lib/client-auth/remote-clients.js | 199 ++ .../lib/client-auth/remote-clients.test.js | 114 + .../server/lib/notifications/DOCUMENTATION.md | 1 + .../server/lib/notifications/push-runtime.js | 28 +- .../lib/notifications/push-runtime.test.js | 45 + .../web/server/lib/notifications/runtime.js | 16 + .../notifications/template-runtime.test.js | 8 +- .../server/lib/opencode/bootstrap-runtime.js | 3 + .../server/lib/opencode/cli-entry-runtime.js | 1 + .../web/server/lib/opencode/cli-options.js | 7 + .../web/server/lib/opencode/core-routes.js | 184 +- .../server/lib/opencode/core-routes.test.js | 186 ++ .../server/lib/opencode/openchamber-routes.js | 4 + packages/web/server/lib/opencode/proxy.js | 59 +- .../lib/opencode/startup-pipeline-runtime.js | 7 +- .../lib/opencode/static-routes-runtime.js | 200 ++ .../opencode/static-routes-runtime.test.js | 60 + .../web/server/lib/preview/proxy-runtime.js | 265 ++- .../server/lib/preview/proxy-runtime.test.js | 130 +- .../server/lib/security/request-security.js | 5 + .../lib/security/request-security.test.js | 20 + packages/web/server/lib/ui-auth/ui-auth.js | 309 ++- .../web/server/lib/ui-auth/ui-auth.test.js | 248 ++ packages/web/server/opencode-proxy.test.js | 133 ++ packages/web/src/api/clientAuth.ts | 63 + packages/web/src/api/files.ts | 28 +- packages/web/src/api/github.ts | 72 +- packages/web/src/api/index.ts | 36 +- packages/web/src/api/notifications.test.ts | 116 + packages/web/src/api/notifications.ts | 98 +- packages/web/src/api/push.ts | 5 +- packages/web/src/api/settings.ts | 7 +- packages/web/src/api/tools.ts | 3 +- packages/web/src/main.tsx | 39 +- packages/web/src/mini-chat-main.tsx | 6 +- packages/web/src/mobile-main.tsx | 17 + packages/web/src/runtimeConfig.ts | 47 + packages/web/vite.config.ts | 1 + scripts/dev-web-hmr.mjs | 3 +- 282 files changed, 16524 insertions(+), 4259 deletions(-) rename {.opencode => .agents}/skills/clack-cli-patterns/SKILL.md (100%) rename {.opencode => .agents}/skills/drag-to-reorder/SKILL.md (100%) rename {.opencode => .agents}/skills/locale-ui-patterns/SKILL.md (100%) rename {.opencode => .agents}/skills/settings-ui-patterns/SKILL.md (100%) rename {.opencode => .agents}/skills/theme-system/SKILL.md (100%) rename {.opencode => .agents}/skills/theme-system/references/adding-themes.md (100%) create mode 100644 .agents/skills/ui-api-decoupling/SKILL.md create mode 100644 packages/ui/src/apps/MobileApp.tsx create mode 100644 packages/ui/src/apps/MobileChangesSurface.tsx create mode 100644 packages/ui/src/apps/MobileFilesSurface.tsx create mode 100644 packages/ui/src/apps/MobileSessionsSheet.tsx create mode 100644 packages/ui/src/apps/MobileSurfaceShell.tsx create mode 100644 packages/ui/src/apps/mobileAppContext.tsx create mode 100644 packages/ui/src/apps/renderMobileApp.tsx create mode 100644 packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts create mode 100644 packages/ui/src/lib/connectionPayload.ts create mode 100644 packages/ui/src/lib/desktopHosts.test.ts create mode 100644 packages/ui/src/lib/mobileLayoutPreference.ts create mode 100644 packages/ui/src/lib/runtime-auth.test.ts create mode 100644 packages/ui/src/lib/runtime-auth.ts create mode 100644 packages/ui/src/lib/runtime-fetch.test.ts create mode 100644 packages/ui/src/lib/runtime-fetch.ts create mode 100644 packages/ui/src/lib/runtime-switch.ts create mode 100644 packages/ui/src/lib/runtime-url.test.ts create mode 100644 packages/ui/src/lib/runtime-url.ts create mode 100644 packages/ui/src/lib/server-compatibility.test.ts create mode 100644 packages/ui/src/lib/server-compatibility.ts create mode 100644 packages/ui/src/stores/useCommandsStore.test.ts create mode 100644 packages/ui/src/sync/runtime-live-memory.ts create mode 100644 packages/ui/src/sync/session-navigation.ts create mode 100644 packages/ui/src/types/zumer-snapdom.d.ts create mode 100644 packages/vscode/src/bridge-config-runtime.test.js create mode 100644 packages/vscode/src/bridge-git-special-runtime.test.js create mode 100644 packages/vscode/src/bridge-proxy-runtime.test.ts create mode 100644 packages/vscode/webview/api/bridge.test.ts create mode 100644 packages/vscode/webview/requestBodyTransport.test.ts create mode 100644 packages/vscode/webview/requestBodyTransport.ts create mode 100644 packages/web/mobile.html create mode 100644 packages/web/server/lib/client-auth/remote-clients.js create mode 100644 packages/web/server/lib/client-auth/remote-clients.test.js create mode 100644 packages/web/server/lib/notifications/push-runtime.test.js create mode 100644 packages/web/server/lib/opencode/static-routes-runtime.test.js create mode 100644 packages/web/server/lib/security/request-security.test.js create mode 100644 packages/web/server/lib/ui-auth/ui-auth.test.js create mode 100644 packages/web/src/api/clientAuth.ts create mode 100644 packages/web/src/api/notifications.test.ts create mode 100644 packages/web/src/mobile-main.tsx create mode 100644 packages/web/src/runtimeConfig.ts diff --git a/.opencode/skills/clack-cli-patterns/SKILL.md b/.agents/skills/clack-cli-patterns/SKILL.md similarity index 100% rename from .opencode/skills/clack-cli-patterns/SKILL.md rename to .agents/skills/clack-cli-patterns/SKILL.md diff --git a/.opencode/skills/drag-to-reorder/SKILL.md b/.agents/skills/drag-to-reorder/SKILL.md similarity index 100% rename from .opencode/skills/drag-to-reorder/SKILL.md rename to .agents/skills/drag-to-reorder/SKILL.md diff --git a/.opencode/skills/locale-ui-patterns/SKILL.md b/.agents/skills/locale-ui-patterns/SKILL.md similarity index 100% rename from .opencode/skills/locale-ui-patterns/SKILL.md rename to .agents/skills/locale-ui-patterns/SKILL.md diff --git a/.opencode/skills/settings-ui-patterns/SKILL.md b/.agents/skills/settings-ui-patterns/SKILL.md similarity index 100% rename from .opencode/skills/settings-ui-patterns/SKILL.md rename to .agents/skills/settings-ui-patterns/SKILL.md diff --git a/.opencode/skills/theme-system/SKILL.md b/.agents/skills/theme-system/SKILL.md similarity index 100% rename from .opencode/skills/theme-system/SKILL.md rename to .agents/skills/theme-system/SKILL.md diff --git a/.opencode/skills/theme-system/references/adding-themes.md b/.agents/skills/theme-system/references/adding-themes.md similarity index 100% rename from .opencode/skills/theme-system/references/adding-themes.md rename to .agents/skills/theme-system/references/adding-themes.md diff --git a/.agents/skills/ui-api-decoupling/SKILL.md b/.agents/skills/ui-api-decoupling/SKILL.md new file mode 100644 index 00000000..6bb2e0ce --- /dev/null +++ b/.agents/skills/ui-api-decoupling/SKILL.md @@ -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 `` 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` diff --git a/AGENTS.md b/AGENTS.md index 4b36e01b..cb4c5747 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:`. 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`) diff --git a/README.md b/README.md index 01e41f8d..ea1a395b 100644 --- a/README.md +++ b/README.md @@ -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. +
@@ -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`** diff --git a/fix-deprecation.js b/fix-deprecation.js index 059058e5..17c49be5 100644 --- a/fix-deprecation.js +++ b/fix-deprecation.js @@ -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(); \ No newline at end of file +fixHttpProxyDeprecation(); diff --git a/package.json b/package.json index 8ab44606..e2971d0e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/docs/content/docs/environment.mdx b/packages/docs/content/docs/environment.mdx index 6b25e4e1..aaa793d6 100644 --- a/packages/docs/content/docs/environment.mdx +++ b/packages/docs/content/docs/environment.mdx @@ -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`. diff --git a/packages/docs/content/docs/es/environment.mdx b/packages/docs/content/docs/es/environment.mdx index be06d227..e6842ded 100644 --- a/packages/docs/content/docs/es/environment.mdx +++ b/packages/docs/content/docs/es/environment.mdx @@ -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`. diff --git a/packages/docs/content/docs/es/opencode-server.mdx b/packages/docs/content/docs/es/opencode-server.mdx index 4fd479e6..db492088 100644 --- a/packages/docs/content/docs/es/opencode-server.mdx +++ b/packages/docs/content/docs/es/opencode-server.mdx @@ -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 diff --git a/packages/docs/content/docs/es/remote-instances.mdx b/packages/docs/content/docs/es/remote-instances.mdx index 345415eb..a46d3100 100644 --- a/packages/docs/content/docs/es/remote-instances.mdx +++ b/packages/docs/content/docs/es/remote-instances.mdx @@ -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 diff --git a/packages/docs/content/docs/es/tunnels.mdx b/packages/docs/content/docs/es/tunnels.mdx index de7ddc50..062fc09c 100644 --- a/packages/docs/content/docs/es/tunnels.mdx +++ b/packages/docs/content/docs/es/tunnels.mdx @@ -30,6 +30,8 @@ ngrok config add-authtoken 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 diff --git a/packages/docs/content/docs/ko/environment.mdx b/packages/docs/content/docs/ko/environment.mdx index e5d95f0e..aebebe09 100644 --- a/packages/docs/content/docs/ko/environment.mdx +++ b/packages/docs/content/docs/ko/environment.mdx @@ -17,6 +17,10 @@ OpenChamber 웹 서버가 바인딩할 주소입니다. 다른 컴퓨터에서 브라우저 UI 비밀번호입니다. localhost 밖으로 바인딩하거나 터널, 리버스 프록시를 사용할 때 설정하세요. +### `OPENCHAMBER_API_ONLY` + +`true` 또는 `1`이면 OpenChamber를 headless 모드로 시작합니다. 데스크톱과 모바일 클라이언트용 API route는 계속 사용할 수 있지만 브라우저 UI는 제공하지 않습니다. + ### `OPENCHAMBER_DATA_DIR` OpenChamber 데이터 디렉터리를 바꿉니다. 기본값은 `~/.config/openchamber`입니다. diff --git a/packages/docs/content/docs/ko/opencode-server.mdx b/packages/docs/content/docs/ko/opencode-server.mdx index 0b4d193a..a345c26c 100644 --- a/packages/docs/content/docs/ko/opencode-server.mdx +++ b/packages/docs/content/docs/ko/opencode-server.mdx @@ -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 diff --git a/packages/docs/content/docs/ko/remote-instances.mdx b/packages/docs/content/docs/ko/remote-instances.mdx index ee2cc776..15a5ae3b 100644 --- a/packages/docs/content/docs/ko/remote-instances.mdx +++ b/packages/docs/content/docs/ko/remote-instances.mdx @@ -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에서 원격 서버에 연결하세요 diff --git a/packages/docs/content/docs/ko/tunnels.mdx b/packages/docs/content/docs/ko/tunnels.mdx index c9f7c6cb..a1b450bb 100644 --- a/packages/docs/content/docs/ko/tunnels.mdx +++ b/packages/docs/content/docs/ko/tunnels.mdx @@ -30,6 +30,8 @@ ngrok config add-authtoken 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` 같은 서버 플래그를 저장합니다 ## 관련 문서 diff --git a/packages/docs/content/docs/opencode-server.mdx b/packages/docs/content/docs/opencode-server.mdx index 53a506f6..fe9b6bac 100644 --- a/packages/docs/content/docs/opencode-server.mdx +++ b/packages/docs/content/docs/opencode-server.mdx @@ -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 diff --git a/packages/docs/content/docs/pl/environment.mdx b/packages/docs/content/docs/pl/environment.mdx index fbb91dc8..9954fc79 100644 --- a/packages/docs/content/docs/pl/environment.mdx +++ b/packages/docs/content/docs/pl/environment.mdx @@ -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`. diff --git a/packages/docs/content/docs/pl/opencode-server.mdx b/packages/docs/content/docs/pl/opencode-server.mdx index 1735eddc..fa647b86 100644 --- a/packages/docs/content/docs/pl/opencode-server.mdx +++ b/packages/docs/content/docs/pl/opencode-server.mdx @@ -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 diff --git a/packages/docs/content/docs/pl/remote-instances.mdx b/packages/docs/content/docs/pl/remote-instances.mdx index b026946b..beb69d99 100644 --- a/packages/docs/content/docs/pl/remote-instances.mdx +++ b/packages/docs/content/docs/pl/remote-instances.mdx @@ -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 diff --git a/packages/docs/content/docs/pl/tunnels.mdx b/packages/docs/content/docs/pl/tunnels.mdx index c5833bfe..95dfbfe7 100644 --- a/packages/docs/content/docs/pl/tunnels.mdx +++ b/packages/docs/content/docs/pl/tunnels.mdx @@ -30,6 +30,8 @@ ngrok config add-authtoken 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 diff --git a/packages/docs/content/docs/pt-br/environment.mdx b/packages/docs/content/docs/pt-br/environment.mdx index 967afc23..0e592a54 100644 --- a/packages/docs/content/docs/pt-br/environment.mdx +++ b/packages/docs/content/docs/pt-br/environment.mdx @@ -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`. diff --git a/packages/docs/content/docs/pt-br/opencode-server.mdx b/packages/docs/content/docs/pt-br/opencode-server.mdx index 5cb30afe..b9951383 100644 --- a/packages/docs/content/docs/pt-br/opencode-server.mdx +++ b/packages/docs/content/docs/pt-br/opencode-server.mdx @@ -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 diff --git a/packages/docs/content/docs/pt-br/remote-instances.mdx b/packages/docs/content/docs/pt-br/remote-instances.mdx index 09e440c2..15b199d5 100644 --- a/packages/docs/content/docs/pt-br/remote-instances.mdx +++ b/packages/docs/content/docs/pt-br/remote-instances.mdx @@ -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 diff --git a/packages/docs/content/docs/pt-br/tunnels.mdx b/packages/docs/content/docs/pt-br/tunnels.mdx index 8aa9a714..37fef87f 100644 --- a/packages/docs/content/docs/pt-br/tunnels.mdx +++ b/packages/docs/content/docs/pt-br/tunnels.mdx @@ -30,6 +30,8 @@ ngrok config add-authtoken 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 diff --git a/packages/docs/content/docs/remote-instances.mdx b/packages/docs/content/docs/remote-instances.mdx index 8edfb832..ab7d7f8d 100644 --- a/packages/docs/content/docs/remote-instances.mdx +++ b/packages/docs/content/docs/remote-instances.mdx @@ -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 diff --git a/packages/docs/content/docs/tunnels.mdx b/packages/docs/content/docs/tunnels.mdx index 2eeb2d90..98de5c79 100644 --- a/packages/docs/content/docs/tunnels.mdx +++ b/packages/docs/content/docs/tunnels.mdx @@ -30,6 +30,8 @@ ngrok config add-authtoken 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 diff --git a/packages/docs/content/docs/uk/environment.mdx b/packages/docs/content/docs/uk/environment.mdx index b8295070..e202ab85 100644 --- a/packages/docs/content/docs/uk/environment.mdx +++ b/packages/docs/content/docs/uk/environment.mdx @@ -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`. diff --git a/packages/docs/content/docs/uk/opencode-server.mdx b/packages/docs/content/docs/uk/opencode-server.mdx index cdfa0fcf..2b159343 100644 --- a/packages/docs/content/docs/uk/opencode-server.mdx +++ b/packages/docs/content/docs/uk/opencode-server.mdx @@ -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 diff --git a/packages/docs/content/docs/uk/remote-instances.mdx b/packages/docs/content/docs/uk/remote-instances.mdx index 6c9885e6..b2eddafb 100644 --- a/packages/docs/content/docs/uk/remote-instances.mdx +++ b/packages/docs/content/docs/uk/remote-instances.mdx @@ -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 diff --git a/packages/docs/content/docs/uk/tunnels.mdx b/packages/docs/content/docs/uk/tunnels.mdx index b6d98397..574ef782 100644 --- a/packages/docs/content/docs/uk/tunnels.mdx +++ b/packages/docs/content/docs/uk/tunnels.mdx @@ -30,6 +30,8 @@ ngrok config add-authtoken openchamber ``` +Якщо пропустити цей крок, `openchamber tunnel start` може автоматично запустити CLI server. Під час auto-start можна передати server options: `--port`, `--host`, `--lan`, `--ui-password` і `--api-only`. + 2. Запустіть тунель Cloudflare: ```bash diff --git a/packages/docs/content/docs/zh-cn/environment.mdx b/packages/docs/content/docs/zh-cn/environment.mdx index 9e44d564..69d94b7f 100644 --- a/packages/docs/content/docs/zh-cn/environment.mdx +++ b/packages/docs/content/docs/zh-cn/environment.mdx @@ -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`。 diff --git a/packages/docs/content/docs/zh-cn/opencode-server.mdx b/packages/docs/content/docs/zh-cn/opencode-server.mdx index bb234925..23224c64 100644 --- a/packages/docs/content/docs/zh-cn/opencode-server.mdx +++ b/packages/docs/content/docs/zh-cn/opencode-server.mdx @@ -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 diff --git a/packages/docs/content/docs/zh-cn/remote-instances.mdx b/packages/docs/content/docs/zh-cn/remote-instances.mdx index df58a177..cdc49ba8 100644 --- a/packages/docs/content/docs/zh-cn/remote-instances.mdx +++ b/packages/docs/content/docs/zh-cn/remote-instances.mdx @@ -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 中连接到远程服务器 diff --git a/packages/docs/content/docs/zh-cn/tunnels.mdx b/packages/docs/content/docs/zh-cn/tunnels.mdx index aa4a416d..7f8a7e53 100644 --- a/packages/docs/content/docs/zh-cn/tunnels.mdx +++ b/packages/docs/content/docs/zh-cn/tunnels.mdx @@ -30,6 +30,8 @@ ngrok config add-authtoken openchamber ``` +如果跳过这一步,`openchamber tunnel start` 可以自动启动 CLI 服务器。自动启动时可以传入服务器选项,例如 `--port`、`--host`、`--lan`、`--ui-password` 和 `--api-only`。 + 2. 启动 Cloudflare 隧道: ```bash diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 7d74f682..00630bb1 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, screen, session, shell, webContents } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, protocol, screen, session, shell, webContents } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -7,7 +7,7 @@ import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { execFile, spawn, spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import updaterPkg from 'electron-updater'; import { ElectronSshManager } from './ssh-manager.mjs'; @@ -19,6 +19,7 @@ const __dirname = path.dirname(__filename); const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged; const DEEP_LINK_PROTOCOL = 'openchamber'; +const UI_PROTOCOL = 'openchamber-ui'; const PACKAGED_APP_USER_MODEL_ID = 'dev.openchamber.desktop'; const DEV_APP_USER_MODEL_ID = 'dev.openchamber.desktop.dev'; const APP_USER_MODEL_ID = app.isPackaged ? PACKAGED_APP_USER_MODEL_ID : DEV_APP_USER_MODEL_ID; @@ -50,6 +51,18 @@ if (isDev) { app.setAppUserModelId(APP_USER_MODEL_ID); app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>'); +protocol.registerSchemesAsPrivileged([ + { + scheme: UI_PROTOCOL, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, +]); + if (!app.requestSingleInstanceLock()) { app.exit(0); process.exit(0); @@ -123,6 +136,8 @@ const APP_METADATA = readAppMetadata(); const APP_VERSION = APP_METADATA.version; const DEFAULT_DESKTOP_PORT = 57123; +const LOOPBACK_BIND_HOST = '127.0.0.1'; +const LAN_BIND_HOST = '0.0.0.0'; const MIN_WINDOW_WIDTH = 800; const MIN_WINDOW_HEIGHT = 520; const MIN_RESTORE_WINDOW_WIDTH = 900; @@ -133,6 +148,8 @@ const MINI_CHAT_MIN_WINDOW_WIDTH = 360; const MINI_CHAT_MIN_WINDOW_HEIGHT = 480; const MAX_CAPTURE_PAGE_RECT_AREA = 4_000_000; const LOCAL_HOST_ID = 'local'; +const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local'; +const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local'; const ENV_OVERRIDE_HOST_ID = '__env'; const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md'; const UPDATE_METADATA_URL = 'https://github.com/openchamber/openchamber/releases/latest/download/latest.json'; @@ -148,6 +165,8 @@ const state = { serverHandle: null, sidecarUrl: null, localOrigin: null, + apiBaseUrl: null, + clientToken: null, bootOutcome: null, initScript: null, mainWindow: null, @@ -399,6 +418,28 @@ const normalizeHostUrl = (raw) => { }; const sanitizeHostUrlForStorage = (raw) => normalizeHostUrl(raw); +const sanitizeClientTokenForStorage = (raw) => { + const token = typeof raw === 'string' ? raw.trim() : ''; + return token.length > 0 ? token : null; +}; + +const sameOrigin = (left, right) => { + if (!left || !right) return false; + try { + return new URL(left).origin === new URL(right).origin; + } catch { + return false; + } +}; + +const readDesktopLocalClientToken = () => { + return sanitizeClientTokenForStorage(readSettingsRoot().desktopLocalClientToken) || ''; +}; + +const isLocalRuntimeUrl = (targetUrl) => { + const localUrl = state.sidecarUrl || state.localOrigin || ''; + return Boolean(localUrl && sameOrigin(targetUrl, localUrl)); +}; const readDesktopHostsConfig = () => { const root = readSettingsRoot(); @@ -408,8 +449,10 @@ const readDesktopHostsConfig = () => { const id = typeof entry?.id === 'string' ? entry.id.trim() : ''; const url = sanitizeHostUrlForStorage(entry?.url); if (!id || id === LOCAL_HOST_ID || !url) return null; + const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; + const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url; - return { id, label, url }; + return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}) }; }) .filter(Boolean); @@ -430,10 +473,14 @@ const writeDesktopHostsConfig = async (config) => { const id = typeof entry?.id === 'string' ? entry.id.trim() : ''; const url = sanitizeHostUrlForStorage(entry?.url); if (!id || id === LOCAL_HOST_ID || !url) return null; + const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; + const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); return { id, label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url, url, + apiUrl, + ...(clientToken ? { clientToken } : {}), }; }) .filter(Boolean) @@ -444,6 +491,14 @@ const writeDesktopHostsConfig = async (config) => { if (typeof config?.initialHostChoiceCompleted === 'boolean') { root.desktopInitialHostChoiceCompleted = config.initialHostChoiceCompleted; } + if (Object.prototype.hasOwnProperty.call(config || {}, 'localClientToken')) { + const localClientToken = sanitizeClientTokenForStorage(config.localClientToken); + if (localClientToken) { + root.desktopLocalClientToken = localClientToken; + } else { + delete root.desktopLocalClientToken; + } + } }); }; @@ -530,18 +585,72 @@ const buildHealthUrl = (url) => { } }; -const probeHostWithTimeout = async (url, timeoutMs) => { - const healthUrl = buildHealthUrl(url); - if (!healthUrl) { +const buildVersionUrl = (url) => { + try { + const parsed = new URL(url); + parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/api/version`; + return parsed.toString(); + } catch { + return null; + } +}; + +const classifyVersionPayload = (payload) => { + const compatibility = payload?.compatibility; + if (!payload || payload.status !== 'ok' || !compatibility || typeof compatibility !== 'object') { + return 'wrong-service'; + } + + if (!Array.isArray(compatibility.capabilities) || !compatibility.capabilities.includes('api.runtime-url.v1')) { + return 'incompatible'; + } + + if (compatibility.apiVersion !== 1 || compatibility.minClientApiVersion > 1) { + return 'update-recommended'; + } + + return 'ok'; +}; + +const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + try { + return await fetch(versionUrl, { signal: timeoutSignal, headers }); + } catch (error) { + if (timeoutSignal.aborted) { + throw error; + } + return await Promise.race([ + electronNet.fetch(versionUrl, { headers }), + new Promise((_, reject) => setTimeout(() => reject(error), timeoutMs)), + ]); + } +}; + +const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => { + const versionUrl = buildVersionUrl(url); + if (!versionUrl) { throw new Error('Invalid URL'); } const started = Date.now(); try { - const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs) }); + const headers = { Accept: 'application/json' }; + const token = typeof clientToken === 'string' ? clientToken.trim() : ''; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const response = await fetchVersionPayload(versionUrl, { headers, timeoutMs }); const status = response.status; + if (status === 401 || status === 403) { + return { status: 'auth', latencyMs: Date.now() - started }; + } + if (status < 200 || status >= 300) { + return { status: 'unreachable', latencyMs: Date.now() - started }; + } + const payload = await response.json().catch(() => null); return { - status: status >= 200 && status < 300 ? 'ok' : (status === 401 || status === 403 ? 'auth' : 'unreachable'), + status: classifyVersionPayload(payload), latencyMs: Date.now() - started, }; } catch { @@ -549,6 +658,22 @@ const probeHostWithTimeout = async (url, timeoutMs) => { } }; +const resolveStoredClientTokenForUrl = (targetUrl, config = readDesktopHostsConfig()) => { + const normalizedTarget = normalizeHostUrl(targetUrl); + if (!normalizedTarget) return ''; + if (isLocalRuntimeUrl(normalizedTarget)) { + return readDesktopLocalClientToken(); + } + for (const host of config.hosts || []) { + const hostUrl = normalizeHostUrl(host?.url || ''); + const apiUrl = normalizeHostUrl(host?.apiUrl || host?.url || ''); + if (normalizedTarget === hostUrl || normalizedTarget === apiUrl) { + return sanitizeClientTokenForStorage(host?.clientToken); + } + } + return ''; +}; + const waitForHealth = async (url, timeoutMs = 20_000, initialPollMs = 250, maxPollMs = 2000) => { const deadline = Date.now() + timeoutMs; let pollMs = initialPollMs; @@ -566,11 +691,11 @@ const waitForHealth = async (url, timeoutMs = 20_000, initialPollMs = 250, maxPo return false; }; -const pickUnusedPort = async () => { +const pickUnusedPort = async (host = '127.0.0.1') => { const net = await import('node:net'); return await new Promise((resolve, reject) => { const server = net.createServer(); - server.listen(0, '127.0.0.1', () => { + server.listen(0, host, () => { const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; server.close(() => resolve(port)); @@ -579,7 +704,7 @@ const pickUnusedPort = async () => { }); }; -const isPortFree = async (port) => { +const isPortFree = async (port, host = '127.0.0.1') => { if (!Number.isFinite(port) || port <= 0) return false; const net = await import('node:net'); return await new Promise((resolve) => { @@ -589,7 +714,7 @@ const isPortFree = async (port) => { resolve(value); }; test.once('error', () => done(false)); - test.listen(port, '127.0.0.1', () => done(true)); + test.listen(port, host, () => done(true)); }); }; @@ -636,6 +761,57 @@ const buildLocalUrl = (port) => `http://127.0.0.1:${port}`; const resourceRoot = () => isDev ? path.join(__dirname, 'resources') : process.resourcesPath; const resolveWebDistDir = () => path.join(resourceRoot(), 'web-dist'); +const shouldUsePackagedUi = () => { + if (process.env.OPENCHAMBER_ELECTRON_LOAD_SERVER_UI === '1') return false; + if (process.env.OPENCHAMBER_ELECTRON_USE_BUNDLED_UI === '1') return true; + return app.isPackaged; +}; +const packagedUiOrigin = () => `${UI_PROTOCOL}://app`; +const buildPackagedUiUrl = (pathname = '/index.html') => new URL(pathname, `${packagedUiOrigin()}/`).toString(); + +const injectRuntimeConfigIntoHtml = (html) => { + const apiBaseUrl = state.apiBaseUrl || state.sidecarUrl || ''; + const localOrigin = state.localOrigin || state.sidecarUrl || ''; + const initScript = ``; + if (html.includes('')) return html.replace('', `${initScript}`); + if (html.includes('')) return html.replace('', `${initScript}`); + return `${initScript}${html}`; +}; + +const registerPackagedUiProtocol = () => { + if (!shouldUsePackagedUi()) return; + protocol.handle(UI_PROTOCOL, async (request) => { + const distPath = resolveWebDistDir(); + let requestedPath = '/index.html'; + try { + const url = new URL(request.url); + requestedPath = decodeURIComponent(url.pathname || '/index.html'); + } catch { + requestedPath = '/index.html'; + } + const normalized = path.normalize(requestedPath).replace(/^([/\\])+/, ''); + const candidate = path.join(distPath, normalized || 'index.html'); + const relative = path.relative(distPath, candidate); + const isInsideDist = relative && !relative.startsWith('..') && !path.isAbsolute(relative); + const filePath = isInsideDist ? candidate : path.join(distPath, 'index.html'); + try { + const info = await fsp.stat(filePath); + if (info.isFile()) { + if (filePath.endsWith('.html')) { + const html = await fsp.readFile(filePath, 'utf8'); + const body = injectRuntimeConfigIntoHtml(html); + return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); + } + return electronNet.fetch(pathToFileURL(filePath).toString()); + } + } catch { + } + const indexPath = path.join(distPath, 'index.html'); + const html = await fsp.readFile(indexPath, 'utf8'); + const body = injectRuntimeConfigIntoHtml(html); + return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); + }); +}; const normalizeNotificationInput = (raw) => { if (!raw || typeof raw !== 'object') return {}; @@ -693,6 +869,9 @@ const maybeShowNativeNotification = (rawInput) => { const sessionId = typeof payload.sessionId === 'string' && payload.sessionId.trim() ? payload.sessionId.trim() : null; + const directory = typeof payload.directory === 'string' && payload.directory.trim() + ? payload.directory.trim() + : null; const notification = new Notification({ title, @@ -707,7 +886,7 @@ const maybeShowNativeNotification = (rawInput) => { notification.on('click', () => { focusForegroundWindow(); if (sessionId) { - emitToAllWindows('openchamber:open-session', { sessionId }); + emitToAllWindows('openchamber:open-session', { sessionId, directory }); } release(); }); @@ -844,7 +1023,7 @@ const spawnLocalServer = async () => { // so phones/tablets on the same Wi-Fi can reach the app. UI shows a clear // warning and persists the flag via /api/config/settings. const lanAccessEnabled = settings.desktopLanAccessEnabled === true; - const bindHost = lanAccessEnabled ? '0.0.0.0' : '127.0.0.1'; + const bindHost = lanAccessEnabled ? LAN_BIND_HOST : LOOPBACK_BIND_HOST; const desktopUiPassword = typeof settings.desktopUiPassword === 'string' ? settings.desktopUiPassword.trim() : ''; // Probe before starting the server — main() in the server module sets up a @@ -853,13 +1032,13 @@ const spawnLocalServer = async () => { const candidates = [storedPort, DEFAULT_DESKTOP_PORT].filter((v) => Number.isFinite(v) && v > 0); let chosenPort = 0; for (const candidate of candidates) { - if (await isPortFree(candidate)) { + if (await isPortFree(candidate, bindHost)) { chosenPort = candidate; break; } } if (chosenPort === 0) { - chosenPort = await pickUnusedPort(); + chosenPort = await pickUnusedPort(bindHost); } // The server module reads ENV_DESKTOP_NOTIFY / OPENCHAMBER_DIST_DIR / @@ -887,6 +1066,7 @@ const spawnLocalServer = async () => { uiPassword: desktopUiPassword || null, attachSignals: false, exitOnShutdown: false, + apiOnly: shouldUsePackagedUi() && !lanAccessEnabled, onDesktopNotification: (payload) => maybeShowNativeNotification(payload), getIsWindowFocused: isAnyWindowFocused, }); @@ -928,21 +1108,30 @@ const macosMajorVersion = () => { return major === 10 ? minor : major; }; -const buildInitScript = (localOrigin, bootOutcome) => { +const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '') => { const home = JSON.stringify(os.homedir() || ''); const local = JSON.stringify(localOrigin || ''); + const apiBase = JSON.stringify(apiBaseUrl || ''); + const token = JSON.stringify(clientToken || ''); + const packagedOrigin = JSON.stringify(packagedUiOrigin()); const macVersion = macosMajorVersion(); const outcome = JSON.stringify(bootOutcome ?? null); return [ '(function(){', - `try{window.__OPENCHAMBER_HOME__=${home};window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=${local};var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, + `try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, '}())', ].join(''); }; const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => { if (envTargetUrl) { - const status = probe && probe.status === 'unreachable' ? 'unreachable' : 'ok'; + const status = probe?.status === 'unreachable' + ? 'unreachable' + : probe?.status === 'incompatible' + ? 'incompatible' + : probe?.status === 'wrong-service' + ? 'wrong-service' + : 'ok'; return { target: 'remote', status, hostId: ENV_OVERRIDE_HOST_ID, url: envTargetUrl }; } @@ -962,8 +1151,14 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => return { target: 'remote', status: 'missing', hostId: defaultId }; } - const status = probe && probe.status === 'unreachable' ? 'unreachable' : 'ok'; - return { target: 'remote', status, hostId: host.id, url: host.url }; + const status = probe?.status === 'unreachable' + ? 'unreachable' + : probe?.status === 'incompatible' + ? 'incompatible' + : probe?.status === 'wrong-service' + ? 'wrong-service' + : 'ok'; + return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url }; }; const buildStartupSplashHtml = () => { @@ -1088,6 +1283,82 @@ const navigateWindow = async (browserWindow, url, { allowAbort = false } = {}) = } }; +const extractCookieHeader = (response) => { + const getSetCookie = typeof response.headers?.getSetCookie === 'function' + ? response.headers.getSetCookie.bind(response.headers) + : null; + const cookies = getSetCookie ? getSetCookie() : []; + const rawCookies = cookies.length > 0 + ? cookies + : String(response.headers?.get?.('set-cookie') || '').split(/,(?=\s*[^;,=]+=[^;,]+)/); + return rawCookies + .map((cookie) => String(cookie || '').split(';')[0].trim()) + .filter(Boolean) + .join('; '); +}; + +const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => { + const baseUrl = normalizeHostUrl(String(url || '')); + const candidatePassword = typeof password === 'string' ? password : ''; + if (!baseUrl) throw new Error('Invalid URL'); + if (!candidatePassword) throw new Error('Password is required'); + + const loginResponse = await fetch(new URL('/auth/session', `${baseUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password: candidatePassword, + trustDevice: trustDevice === true, + issueClientToken: true, + clientLabel: 'OpenChamber Desktop', + ...(isLocalRuntimeUrl(baseUrl) ? { + clientKind: LOCAL_DESKTOP_CLIENT_KIND, + dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY, + } : {}), + }), + }); + if (!loginResponse.ok) { + return { ok: false, status: loginResponse.status }; + } + + const loginPayload = await loginResponse.json().catch(() => null); + if (typeof loginPayload?.clientToken === 'string' && loginPayload.clientToken.trim()) { + return { ok: true, token: loginPayload.clientToken.trim() }; + } + + const cookie = extractCookieHeader(loginResponse); + if (!cookie) { + return { ok: false, status: 401 }; + } + + const tokenResponse = await fetch(new URL('/api/client-auth/clients', `${baseUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cookie: cookie, + }, + body: JSON.stringify({ + label: 'OpenChamber Desktop', + ...(isLocalRuntimeUrl(baseUrl) ? { + clientKind: LOCAL_DESKTOP_CLIENT_KIND, + dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY, + } : {}), + }), + }); + if (!tokenResponse.ok) { + return { ok: false, status: tokenResponse.status }; + } + const tokenPayload = await tokenResponse.json().catch(() => null); + const token = typeof tokenPayload?.token === 'string' ? tokenPayload.token.trim() : ''; + return token ? { ok: true, token } : { ok: false, status: 500 }; +}; + const emitToWindow = (browserWindow, event, detail) => { if (!browserWindow || browserWindow.isDestroyed()) return; browserWindow.webContents.send('openchamber:emit', { event, detail }); @@ -1123,41 +1394,144 @@ const parseDeepLink = (raw) => { const value = segments.length > 0 ? decodeURIComponent(segments.join('/')) : ''; - return { type, value }; + return { type, value, raw: trimmed }; } catch { return null; } }; +const parseConnectDeepLinkPayload = (raw) => { + if (typeof raw !== 'string') return null; + try { + const url = new URL(raw.trim()); + if (url.protocol !== `${DEEP_LINK_PROTOCOL}:` || url.hostname !== 'connect') return null; + const version = url.searchParams.get('v'); + const serverUrl = normalizeHostUrl(url.searchParams.get('server') || ''); + const token = sanitizeClientTokenForStorage(url.searchParams.get('token') || ''); + const label = typeof url.searchParams.get('label') === 'string' + ? url.searchParams.get('label').trim() + : ''; + if (version !== '1' || !serverUrl || !token) return null; + return { serverUrl, token, label: label || serverUrl }; + } catch { + return null; + } +}; + +const importConnectDeepLink = async (payload) => { + if (!payload?.serverUrl || !payload?.token) return null; + const config = readDesktopHostsConfig(); + const existing = config.hosts.find((host) => { + const hostUrl = normalizeHostUrl(host?.url || ''); + const apiUrl = normalizeHostUrl(host?.apiUrl || host?.url || ''); + return payload.serverUrl === hostUrl || payload.serverUrl === apiUrl; + }); + + const id = existing?.id || `host-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const importedHost = { + ...(existing || {}), + id, + label: payload.label || existing?.label || payload.serverUrl, + url: payload.serverUrl, + apiUrl: payload.serverUrl, + clientToken: payload.token, + }; + const hosts = existing + ? config.hosts.map((host) => (host.id === existing.id ? importedHost : host)) + : [importedHost, ...config.hosts]; + await writeDesktopHostsConfig({ + ...config, + hosts, + defaultHostId: config.defaultHostId || id, + initialHostChoiceCompleted: true, + }); + return id; +}; + const switchToHostById = async (rawId) => { const id = typeof rawId === 'string' ? rawId.trim() : ''; if (!id) return; const config = readDesktopHostsConfig(); let targetUrl = null; + let apiBaseUrl = null; + let clientToken = ''; if (id === LOCAL_HOST_ID) { - targetUrl = state.sidecarUrl || state.localOrigin; + targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin); + apiBaseUrl = state.sidecarUrl; + clientToken = readDesktopLocalClientToken(); } else { const host = config.hosts.find((entry) => entry.id === id); if (!host) { log.warn('[electron] deep-link host not found:', id); return; } - targetUrl = host.url; + targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url; + apiBaseUrl = host.apiUrl || host.url; + clientToken = host.clientToken || ''; } - if (!targetUrl) { + if (!targetUrl || !apiBaseUrl) { log.warn('[electron] deep-link host has no target URL:', id); return; } const bootOutcome = id === LOCAL_HOST_ID ? { target: 'local', status: 'ok' } - : { target: 'remote', status: 'ok', hostId: id, url: targetUrl }; + : { target: 'remote', status: 'ok', hostId: id, url: apiBaseUrl }; log.info('[electron] switching to host', { id, bootOutcome }); - await activateMainWindow(targetUrl, state.localOrigin, bootOutcome); + await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken }); +}; + +const confirmConnectDeepLink = async (payload) => { + // A connect deep-link can be triggered from a browser/email/chat with no + // in-app interaction. Importing it stores a client token and points all of + // this app's API traffic at the given server, so require explicit consent + // BEFORE writing anything to the hosts config. Never surface the token. + const visible = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed() && window.isVisible()); + if (visible) { + visible.show(); + visible.focus(); + } + const options = { + type: 'warning', + title: 'Connect to OpenChamber server?', + message: `Connect to "${payload.label}"?`, + detail: + `This will add ${payload.serverUrl} as a remote instance and route this app's activity ` + + 'through it. Only continue if you trust this server and started the connection yourself.', + buttons: ['Connect', 'Cancel'], + defaultId: 1, + cancelId: 1, + }; + try { + const result = visible + ? await dialog.showMessageBox(visible, options) + : await dialog.showMessageBox(options); + return result.response === 0; + } catch (error) { + log.warn('[electron] connect deep-link confirmation failed:', error); + return false; + } }; const dispatchDeepLink = (link) => { if (!link) return; log.info('[electron] dispatching deep-link', { type: link.type, valueLen: link.value?.length || 0 }); + if (link.type === 'connect') { + const payload = parseConnectDeepLinkPayload(link.raw); + if (!payload) { + log.warn('[electron] invalid connect deep-link payload'); + return; + } + void confirmConnectDeepLink(payload).then((confirmed) => { + if (!confirmed) { + log.info('[electron] connect deep-link declined by user'); + return; + } + return importConnectDeepLink(payload).then((id) => { + if (id) void switchToHostById(id); + }); + }); + return; + } if (link.type === 'session' && link.value) { emitToAllWindows('openchamber:open-session', { sessionId: link.value }); return; @@ -1280,11 +1654,13 @@ const canUseTitleBarOverlay = (browserWindow) => ( !browserWindow.isDestroyed() ); -const createBrowserWindow = ({ label, restoreGeometry, url }) => { +const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }) => { const saved = restoreGeometry ? readWindowState() : null; const useSaved = saved && typeof saved.width === 'number' && typeof saved.height === 'number'; const restoredBounds = useSaved ? clampWindowBoundsToVisibleWorkArea(saved) : null; - const desktopLocalOrigin = state.localOrigin || ''; + const desktopLocalOrigin = state.localOrigin || state.sidecarUrl || ''; + const desktopApiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : (state.apiBaseUrl || ''); + const desktopClientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : (state.clientToken || ''); const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32'; @@ -1314,6 +1690,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { webPreferences: { additionalArguments: [ `--openchamber-local-origin=${desktopLocalOrigin}`, + `--openchamber-api-base-url=${desktopApiBaseUrl}`, + `--openchamber-client-token=${desktopClientToken}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, `--openchamber-boot-outcome=${JSON.stringify(state.bootOutcome || null)}`, @@ -1333,6 +1711,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { const browserWindow = new BrowserWindow(options); browserWindow.__ocLabel = label || nextWindowLabel(); + browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken }; + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled; if (useSaved && saved.maximized) { @@ -1421,16 +1801,20 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { const isAllowedNavigationUrl = (raw) => { try { const url = new URL(raw); - if (url.protocol === 'file:' || url.protocol === 'about:' || url.protocol === 'devtools:') return true; + if (url.protocol === 'devtools:') return true; if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; - const hostname = url.hostname; - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true; if (state.localOrigin) { try { if (new URL(state.localOrigin).origin === url.origin) return true; } catch { } } + if (state.sidecarUrl) { + try { + if (new URL(state.sidecarUrl).origin === url.origin) return true; + } catch { + } + } const hosts = readDesktopHostsConfig()?.hosts || []; for (const entry of hosts) { if (typeof entry?.url !== 'string') continue; @@ -1465,8 +1849,9 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { }); browserWindow.webContents.on('dom-ready', () => { - if (state.initScript) { - void browserWindow.webContents.executeJavaScript(state.initScript).catch(() => {}); + const initScript = browserWindow.__ocInitScript || state.initScript; + if (initScript) { + void browserWindow.webContents.executeJavaScript(initScript).catch(() => {}); } }); @@ -1496,13 +1881,17 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { return browserWindow; }; -const activateMainWindow = async (url, localOrigin, bootOutcome) => { +const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = {}) => { state.localOrigin = localOrigin; + state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl; + state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : ''; state.bootOutcome = bootOutcome ?? null; - state.initScript = buildInitScript(localOrigin, state.bootOutcome); + state.initScript = buildInitScript(localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken); const mainWindow = state.mainWindow; if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.__ocRuntimeConfig = { apiBaseUrl: state.apiBaseUrl || '', clientToken: state.clientToken || '' }; + mainWindow.__ocInitScript = state.initScript; await navigateWindow(mainWindow, url, { allowAbort: true }); mainWindow.show(); mainWindow.focus(); @@ -1513,26 +1902,31 @@ const activateMainWindow = async (url, localOrigin, bootOutcome) => { label: 'main', restoreGeometry: true, url, + runtimeConfig, }); return state.mainWindow; }; const openMainWindow = async () => { if (!state.localOrigin) { - const { initialUrl, localOrigin, bootOutcome } = await resolveInitialUrl(); - return activateMainWindow(initialUrl, localOrigin, bootOutcome); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); + return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); } const config = readDesktopHostsConfig(); - const localUiUrl = state.sidecarUrl || state.localOrigin; + const localUiUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin); const host = config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID ? config.hosts.find((entry) => entry.id === config.defaultHostId) : null; - const targetUrl = host?.url && !state.unreachableHosts.has(host.url) ? host.url : localUiUrl; - return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome); + const apiBaseUrl = host?.apiUrl || host?.url || state.sidecarUrl || state.apiBaseUrl || ''; + const clientToken = host?.clientToken || resolveStoredClientTokenForUrl(apiBaseUrl, config) || state.clientToken || ''; + const targetUrl = host?.url && apiBaseUrl && !state.unreachableHosts.has(apiBaseUrl) + ? (shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url) + : localUiUrl; + return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken }); }; -const createAdditionalWindow = async (url) => { +const createAdditionalWindow = async (url, runtimeConfig = {}) => { if (!state.localOrigin) { return null; } @@ -1540,6 +1934,7 @@ const createAdditionalWindow = async (url) => { label: nextWindowLabel(), restoreGeometry: false, url, + runtimeConfig, }); return browserWindow; }; @@ -1550,7 +1945,7 @@ const buildMiniChatUrl = ({ mode, sessionId, directory, projectId }) => { throw new Error('Local UI is not available'); } - const url = new URL('/mini-chat.html', base); + const url = new URL(shouldUsePackagedUi() ? buildPackagedUiUrl('/mini-chat.html') : '/mini-chat.html', base); url.searchParams.set('mode', mode === 'session' ? 'session' : 'draft'); if (sessionId) url.searchParams.set('sessionId', sessionId); if (directory) url.searchParams.set('directory', directory); @@ -1558,19 +1953,44 @@ const buildMiniChatUrl = ({ mode, sessionId, directory, projectId }) => { return url.toString(); }; -const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', projectId = '' } = {}) => { +const miniChatSessionWindowKey = (runtimeConfig, sessionId) => { + const runtimeKey = normalizeHostUrl(runtimeConfig?.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || '') || 'local'; + return `${runtimeKey}\n${sessionId}`; +}; + +const getWindowRuntimeConfig = (browserWindow) => { + const fallback = { + apiBaseUrl: state.apiBaseUrl || state.localOrigin || state.sidecarUrl || '', + clientToken: state.clientToken || '', + }; + if (!browserWindow || browserWindow.isDestroyed()) return fallback; + const config = browserWindow.__ocRuntimeConfig; + return { + apiBaseUrl: typeof config?.apiBaseUrl === 'string' ? config.apiBaseUrl : fallback.apiBaseUrl, + clientToken: typeof config?.clientToken === 'string' ? config.clientToken : fallback.clientToken, + }; +}; + +const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', projectId = '', runtimeConfig = {} } = {}) => { + const effectiveRuntimeConfig = { + apiBaseUrl: normalizeHostUrl(runtimeConfig.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || ''), + clientToken: sanitizeClientTokenForStorage(runtimeConfig.clientToken || state.clientToken || ''), + }; + const sessionWindowKey = mode === 'session' && sessionId ? miniChatSessionWindowKey(effectiveRuntimeConfig, sessionId) : ''; if (mode === 'session' && sessionId) { - const existing = state.miniChatWindowsBySession.get(sessionId); + const existing = state.miniChatWindowsBySession.get(sessionWindowKey); if (existing && !existing.isDestroyed()) { if (existing.isMinimized()) existing.restore(); existing.show(); existing.focus(); return existing; } - state.miniChatWindowsBySession.delete(sessionId); + state.miniChatWindowsBySession.delete(sessionWindowKey); } const desktopLocalOrigin = state.localOrigin || ''; + const desktopApiBaseUrl = effectiveRuntimeConfig.apiBaseUrl || ''; + const desktopClientToken = effectiveRuntimeConfig.clientToken || ''; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); const browserWindow = new BrowserWindow({ @@ -1587,6 +2007,8 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj webPreferences: { additionalArguments: [ `--openchamber-local-origin=${desktopLocalOrigin}`, + `--openchamber-api-base-url=${desktopApiBaseUrl}`, + `--openchamber-client-token=${desktopClientToken}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, ], @@ -1600,12 +2022,14 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj }, }); browserWindow.__ocLabel = nextWindowLabel(); + browserWindow.__ocRuntimeConfig = effectiveRuntimeConfig; + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); browserWindow.__ocMiniChat = true; - browserWindow.__ocMiniChatSessionId = mode === 'session' ? sessionId : ''; + browserWindow.__ocMiniChatSessionId = sessionWindowKey; browserWindow.__ocPinned = false; - if (mode === 'session' && sessionId) { - state.miniChatWindowsBySession.set(sessionId, browserWindow); + if (sessionWindowKey) { + state.miniChatWindowsBySession.set(sessionWindowKey, browserWindow); } browserWindow.on('closed', () => { @@ -1641,7 +2065,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj browserWindow.webContents.on('will-navigate', (event, url) => { try { const target = new URL(url); - const local = new URL(state.localOrigin || state.sidecarUrl || ''); + const local = new URL(shouldUsePackagedUi() ? packagedUiOrigin() : (state.localOrigin || state.sidecarUrl || '')); if (target.origin === local.origin) return; } catch { } @@ -1649,8 +2073,9 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj void shell.openExternal(url).catch(() => {}); }); browserWindow.webContents.on('dom-ready', () => { - if (state.initScript) { - void browserWindow.webContents.executeJavaScript(state.initScript).catch(() => {}); + const initScript = browserWindow.__ocInitScript || state.initScript; + if (initScript) { + void browserWindow.webContents.executeJavaScript(initScript).catch(() => {}); } }); @@ -1678,6 +2103,19 @@ const setMiniChatPinned = (browserWindow, pinned) => { return { pinned: nextPinned }; }; +const resolveMiniChatRuntimeConfig = (browserWindow, args = {}) => { + const windowConfig = getWindowRuntimeConfig(browserWindow); + const argApiBaseUrl = typeof args.apiBaseUrl === 'string' ? args.apiBaseUrl : ''; + const targetUrl = normalizeHostUrl(argApiBaseUrl || windowConfig.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || ''); + const providedToken = sanitizeClientTokenForStorage(args.clientToken); + const storedToken = targetUrl ? resolveStoredClientTokenForUrl(targetUrl) : ''; + const windowToken = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.clientToken : ''; + return { + apiBaseUrl: targetUrl, + clientToken: providedToken || windowToken || storedToken || '', + }; +}; + const resolveInitialUrl = async () => { const hmrApiPort = process.env.OPENCHAMBER_HMR_API_PORT || '3901'; const hmrUiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5173'; @@ -1687,35 +2125,45 @@ const resolveInitialUrl = async () => { ? hmrApiUrl : await spawnLocalServer(); - const localUiUrl = isDev && await waitForHealth(hmrUiUrl, 8_000, 100) + const localUiUrl = shouldUsePackagedUi() + ? buildPackagedUiUrl('/index.html') + : isDev && await waitForHealth(hmrUiUrl, 8_000, 100) ? hmrUiUrl : localUrl; state.sidecarUrl = localUrl; const localAvailable = Boolean(localUrl); - const localOrigin = new URL(localUiUrl).origin; + const localOrigin = new URL(localUrl).origin; let initialUrl = localUiUrl; + let apiBaseUrl = localUrl; + let clientToken = readDesktopLocalClientToken(); let remoteProbe = null; const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || ''); const config = readDesktopHostsConfig(); if (envTarget) { - initialUrl = envTarget; + apiBaseUrl = envTarget; + clientToken = ''; + initialUrl = shouldUsePackagedUi() ? localUiUrl : envTarget; } else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); if (host?.url) { - initialUrl = host.url; + apiBaseUrl = host.apiUrl || host.url; + clientToken = host.clientToken || ''; + initialUrl = shouldUsePackagedUi() ? localUiUrl : host.url; } } - if (initialUrl !== localUiUrl) { - remoteProbe = await probeHostWithTimeout(initialUrl, 2_000); + if (apiBaseUrl && apiBaseUrl !== localUrl) { + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000); if (remoteProbe.status === 'unreachable') { - remoteProbe = await probeHostWithTimeout(initialUrl, 10_000); + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000); } if (remoteProbe.status === 'unreachable') { - state.unreachableHosts.add(initialUrl); + state.unreachableHosts.add(apiBaseUrl); + apiBaseUrl = localUrl; + clientToken = readDesktopLocalClientToken(); initialUrl = localUiUrl; } } @@ -1727,7 +2175,7 @@ const resolveInitialUrl = async () => { localAvailable, }); - return { initialUrl, localOrigin, localUiUrl, bootOutcome }; + return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken }; }; const compareSemver = (left, right) => { @@ -2510,25 +2958,42 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } case 'desktop_hosts_get': - return readDesktopHostsConfig(); + return { + ...readDesktopHostsConfig(), + localOrigin: state.localOrigin || state.sidecarUrl || null, + }; case 'desktop_hosts_set': { - await writeDesktopHostsConfig(args.input || args.config || {}); + const nextConfigInput = args.input || args.config || {}; + await writeDesktopHostsConfig(nextConfigInput); const updatedConfig = readDesktopHostsConfig(); const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || ''); + if (Object.prototype.hasOwnProperty.call(nextConfigInput, 'localClientToken') && isLocalRuntimeUrl(state.apiBaseUrl || state.sidecarUrl || state.localOrigin || '')) { + state.clientToken = readDesktopLocalClientToken(); + } state.bootOutcome = computeBootOutcome({ envTargetUrl: envTarget || null, probe: null, config: updatedConfig, localAvailable: Boolean(state.sidecarUrl || state.localOrigin), }); - state.initScript = buildInitScript(state.localOrigin, state.bootOutcome); + state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken); log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome); return null; } + case 'desktop_local_client_token_get': + return readDesktopLocalClientToken(); + case 'desktop_host_probe': - return probeHostWithTimeout(String(args.url || ''), 2_000); + return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || '')); + + case 'desktop_remote_password_login': + return loginRemoteAndIssueClientToken({ + url: args.url, + password: args.password, + trustDevice: args.trustDevice === true, + }); case 'desktop_set_window_theme': { const mode = typeof args.themeMode === 'string' ? args.themeMode : ''; @@ -2704,15 +3169,24 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_new_window': { const config = readDesktopHostsConfig(); - const localUiUrl = state.sidecarUrl || state.localOrigin; + const localUiUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin); let targetUrl = localUiUrl; + let runtimeConfig = { + apiBaseUrl: state.sidecarUrl || state.localOrigin || '', + clientToken: readDesktopLocalClientToken(), + }; if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); - if (host?.url && !state.unreachableHosts.has(host.url)) { - targetUrl = host.url; + const apiUrl = host?.apiUrl || host?.url; + if (host?.url && apiUrl && !state.unreachableHosts.has(apiUrl)) { + targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url; + runtimeConfig = { + apiBaseUrl: normalizeHostUrl(apiUrl), + clientToken: sanitizeClientTokenForStorage(host.clientToken), + }; } } - await createAdditionalWindow(targetUrl); + await createAdditionalWindow(targetUrl, runtimeConfig); return null; } @@ -2721,7 +3195,15 @@ const handleInvoke = async (browserWindow, command, args = {}) => { if (!targetUrl) { throw new Error('Invalid URL'); } - await createAdditionalWindow(targetUrl); + const config = readDesktopHostsConfig(); + const providedToken = typeof args.clientToken === 'string' ? args.clientToken : ''; + const clientToken = sanitizeClientTokenForStorage(providedToken) || resolveStoredClientTokenForUrl(targetUrl, config); + let windowUrl = targetUrl; + const runtimeConfig = { apiBaseUrl: targetUrl, clientToken }; + if (shouldUsePackagedUi()) { + windowUrl = buildPackagedUiUrl('/index.html'); + } + await createAdditionalWindow(windowUrl, runtimeConfig); return null; } @@ -2729,14 +3211,14 @@ const handleInvoke = async (browserWindow, command, args = {}) => { const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''; if (!sessionId) throw new Error('Session id is required'); const directory = typeof args.directory === 'string' ? args.directory.trim() : ''; - await createMiniChatWindow({ mode: 'session', sessionId, directory }); + await createMiniChatWindow({ mode: 'session', sessionId, directory, runtimeConfig: resolveMiniChatRuntimeConfig(browserWindow, args) }); return null; } case 'desktop_open_draft_mini_chat_window': { const directory = typeof args.directory === 'string' ? args.directory.trim() : ''; const projectId = typeof args.projectId === 'string' ? args.projectId.trim() : ''; - await createMiniChatWindow({ mode: 'draft', directory, projectId }); + await createMiniChatWindow({ mode: 'draft', directory, projectId, runtimeConfig: resolveMiniChatRuntimeConfig(browserWindow, args) }); return null; } @@ -3101,11 +3583,9 @@ const isLocalSender = (webContents) => { try { const raw = typeof webContents?.getURL === 'function' ? webContents.getURL() : ''; if (!raw) return false; - if (raw.startsWith('file://') || raw === 'about:blank') return true; const url = new URL(raw); + if (url.protocol === `${UI_PROTOCOL}:` && url.hostname === 'app') return true; if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; - const hostname = url.hostname; - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true; if (state.localOrigin) { try { const allowed = new URL(state.localOrigin); @@ -3113,6 +3593,13 @@ const isLocalSender = (webContents) => { } catch { } } + if (state.sidecarUrl) { + try { + const allowed = new URL(state.sidecarUrl); + if (allowed.origin === url.origin) return true; + } catch { + } + } return false; } catch { return false; @@ -3249,6 +3736,7 @@ app.whenReady().then(async () => { loginItemSettings, }); nativeTheme.themeSource = readThemeSource(); + registerPackagedUiProtocol(); setupAutoUpdater(); if (process.platform === 'darwin') { @@ -3284,8 +3772,8 @@ app.whenReady().then(async () => { const initial = extractInitialDeepLinks(); if (initial.length > 0) handleDeepLinks(initial); - const { initialUrl, localOrigin, bootOutcome } = await resolveInitialUrl(); - await activateMainWindow(initialUrl, localOrigin, bootOutcome); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); + await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); // Notify renderer on OS wake-from-sleep so the SSE event pipeline can // reconnect immediately instead of waiting for the heartbeat watchdog. diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index 9fb042f5..e4e32d48 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -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). diff --git a/packages/electron/scripts/electron-dev.mjs b/packages/electron/scripts/electron-dev.mjs index 816a5a83..8269c31d 100644 --- a/packages/electron/scripts/electron-dev.mjs +++ b/packages/electron/scripts/electron-dev.mjs @@ -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); }); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 5347682a..a8981772 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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 ( - +
@@ -853,7 +882,7 @@ function App({ apis }: AppProps) { return ( - + diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index 40bbc928..25232a2a 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -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; diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx new file mode 100644 index 00000000..aa686406 --- /dev/null +++ b/packages/ui/src/apps/MobileApp.tsx @@ -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 ( +
+ + ))} +
+ +
+ ); +}; + +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 ( +
+
+ + + + + +
+
+ ); +}; + +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( + () => ({ + 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 ( + +
+ setSessionsSheetOpen(true)} + onOpenMenu={() => setOverflowOpen(true)} + /> +
+ + + +
+ + setOverflowOpen(false)} + items={overflowItems} + /> + + {sessionsSheetOpen ? ( + + ) : null} + + setFilesOpen(false)} + ariaLabel={t('mobile.menu.files')} + headerless + > + + setFilesOpen(false)} /> + + + + + + + + + + setSettingsOpen(false)} + ariaLabel={t('mobile.menu.settings')} + headerless + > + + setSettingsOpen(false)} + /> + + +
+
+ ); +}; + +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(); + 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 ( + + + + +
+ + + +
+
+
+
+
+ ); +} diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx new file mode 100644 index 00000000..34d85f07 --- /dev/null +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -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 = ({ 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(null); + const [commitAction, setCommitAction] = React.useState(null); + const [commitMessage, setCommitMessage] = React.useState(''); + const [revertingPaths, setRevertingPaths] = React.useState>(new Set()); + const [isRevertingAll, setIsRevertingAll] = React.useState(false); + const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false); + const [generatedHighlights, setGeneratedHighlights] = React.useState([]); + const [visibleChangePaths, setVisibleChangePaths] = React.useState([]); + const [remotes, setRemotes] = React.useState([]); + const [remoteUrl, setRemoteUrl] = React.useState(null); + const [diffLoadError, setDiffLoadError] = React.useState(null); + const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); + + const changeEntries = React.useMemo(() => { + const files = status?.files ?? []; + const unique = new Map(); + 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(() => { + 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, 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(() => { + 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 ; + } + + if (isLoadingStatus && isGitRepo === null) { + return ; + } + + if (isGitRepo === false) { + return ; + } + + if (route.type === 'diff') { + return ( + setRoute({ type: 'list' })} + onRetry={() => setDiffRetryNonce((value) => value + 1)} + /> + ); + } + + return ( +
+
+ {onClose ? ( + + ) : null} +
+

{t('mobile.nav.changes')}

+

+ {status?.current || currentDirectory} +

+
+ 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} + /> +
+ + {changeEntries.length > 0 ? ( +
+ + void handleCommit({ pushAfter: false })} + onCommitAndPush={() => void handleCommit({ pushAfter: true })} + commitAction={commitAction} + gitmojiEnabled={false} + onOpenGitmojiPicker={() => {}} + /> +
+ ) : ( + + )} +
+
+ ); +}; + +const MobileChangesState: React.FC<{ + message: string; + description?: string; + loading?: boolean; + icon?: boolean; +}> = ({ message, description, loading = false, icon = false }) => ( +
+
+ {loading ? : null} + {icon ? : null} +

{message}

+ {description ?

{description}

: null} +
+
+); + +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 ( +
+
+ +
+

{path}

+
+
+
+ {!fileExists ? ( + + ) : error ? ( +
+
+

{t('mobile.changes.diffDetail.loadFailed')}

+

{error}

+ +
+
+ ) : !diff ? ( + + ) : diff.isBinary ? ( + + ) : isImageFile(path) ? ( + + ) : ( + + + + )} +
+
+ ); +}; diff --git a/packages/ui/src/apps/MobileFilesSurface.tsx b/packages/ui/src/apps/MobileFilesSurface.tsx new file mode 100644 index 00000000..f42d6378 --- /dev/null +++ b/packages/ui/src/apps/MobileFilesSurface.tsx @@ -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 = ({ onClose }) => { + const { t } = useI18n(); + const { files } = useRuntimeAPIs(); + const root = normalizePath(useEffectiveDirectory() ?? null); + const [route, setRoute] = React.useState(() => ({ type: 'browser', directory: root })); + const [entries, setEntries] = React.useState([]); + const [isLoadingDirectory, setIsLoadingDirectory] = React.useState(false); + const [directoryError, setDirectoryError] = React.useState(null); + const [query, setQuery] = React.useState(''); + const [searchResults, setSearchResults] = React.useState([]); + const [isSearching, setIsSearching] = React.useState(false); + const [fileContent, setFileContent] = React.useState(''); + const [fileError, setFileError] = React.useState(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 ; + } + + if (route.type === 'file') { + return ( + 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 ( +
+
+ {onClose ? ( + + ) : null} + {canGoBack && parentDirectory ? ( + + ) : null} +
+

{directoryLabel}

+
+ +
+
+
+ + setQuery(event.target.value)} + placeholder={t('mobile.files.search.placeholder')} + className="h-11 pl-9" + /> +
+
+ + + {directoryError ? ( + + ) : query.trim() ? ( + + ) : ( +
+ {entries.length === 0 && !isLoadingDirectory ? ( +
{t('mobile.files.empty.directory')}
+ ) : null} + {entries.map((entry) => ( + entry.isDirectory ? openDirectory(entry.path) : openFile(entry.path)} + /> + ))} +
+ )} +
+
+ ); +}; + +const MobileFileRow: React.FC<{ + name: string; + path: string; + directory: boolean; + meta?: string; + onClick: () => void; +}> = ({ name, path, directory, meta, onClick }) => ( + +); + +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 ; + if (results.length === 0) return ; + return ( +
+ {results.map((result) => ( + onOpenFile(result.path)} + /> + ))} +
+ ); +}; + +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 ( +
+
+ +
+

{getNameFromPath(path)}

+
+ {!isImageFile(path) ? ( + + ) : null} + +
+
+ {isLoading || imageAuthLoading ? ( + + ) : error ? ( + + ) : isImageFile(path) && imageSrc ? ( + + {getNameFromPath(path)} + + ) : isImageFile(path) ? ( + + {getNameFromPath(path)} + + ) : ( + + )} +
+
+ ); +}; + +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 ( + + + + ); + } + if (isJsonFile(path)) { + return ; + } + return ( +
+ + + +
+ ); +}; + +const MobileFilesState: React.FC<{ message: string; loading?: boolean }> = ({ message, loading = false }) => ( +
+
+ {loading ? : } +

{message}

+
+
+); diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx new file mode 100644 index 00000000..8fb2e576 --- /dev/null +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -0,0 +1,1162 @@ +import React from 'react'; +import { + RiAddLine, + RiArrowDownLine, + RiArrowDownSLine, + RiArrowUpLine, + RiCheckLine, + RiCloseLine, + RiDeleteBinLine, + RiDragMove2Line, + RiEdit2Line, + RiFolder6Line, + RiFolderAddLine, + RiSearchLine, +} from '@remixicon/react'; +import type { Session } from '@opencode-ai/sdk/v2/client'; +import { + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +import { DirectoryExplorerDialog } from '@/components/session/DirectoryExplorerDialog'; +import { Icon } from '@/components/icon/Icon'; +import { NewWorktreeDialog } from '@/components/session/NewWorktreeDialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollShadow } from '@/components/ui/ScrollShadow'; +import { toast } from '@/components/ui'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useI18n } from '@/lib/i18n'; +import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; +import { cn } from '@/lib/utils'; +import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useAllLiveSessions } from '@/sync/sync-context'; +import type { WorktreeMetadata } from '@/types/worktree'; + +import { MobileSurfaceShell } from './MobileSurfaceShell'; + +type MobileSessionsSheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +type ProjectMeta = { + id: string; + label: string; + path: string; + icon?: string | null; + color?: string | null; + iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null; + iconBackground?: string | null; + isGitRepo: boolean; + worktrees: WorktreeMetadata[]; +}; + +type WorktreeBucket = { + /** Stable key — usually the worktree path (or project root). */ + key: string; + /** Display label — branch name when available, else folder name. */ + label: string; + /** Filesystem path used as `directory` for new sessions started here. */ + path: string; + /** Underlying worktree metadata, null when this bucket represents the project root. */ + worktree: WorktreeMetadata | null; + /** Sessions matched into this bucket, sorted by recency desc. */ + sessions: Session[]; +}; + +type ProjectNode = { + project: ProjectMeta; + buckets: WorktreeBucket[]; + totalSessions: number; + isActive: boolean; +}; + +const SESSIONS_PER_BUCKET = 7; + +const normalizePath = (value?: string | null): string => + (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); + +const getSessionDirectory = (session: Session): string => { + const sessionWithDirectory = session as Session & { + directory?: string | null; + project?: { worktree?: string | null } | null; + }; + return normalizePath(sessionWithDirectory.directory ?? sessionWithDirectory.project?.worktree ?? null); +}; + +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; +}; + +const getSessionTimestamp = (session: Session): number => { + const raw = session.time?.updated ?? session.time?.created; + const value = typeof raw === 'number' ? raw : Number(raw); + return Number.isFinite(value) && value > 0 ? value : 0; +}; + +const formatRelativeShort = (timestamp: number): string => { + if (timestamp <= 0) return ''; + const diffMs = Date.now() - timestamp; + if (diffMs < 60_000) return 'now'; + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d`; + return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(new Date(timestamp)); +}; + +const pathBelongsToRoot = (path: string, root: string): boolean => { + const normalizedPath = normalizePath(path); + const normalizedRoot = normalizePath(root); + return Boolean( + normalizedPath && + normalizedRoot && + (normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`)), + ); +}; + +const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => { + if (!query) return true; + const haystack = `${session.title ?? ''} ${session.id} ${getSessionDirectory(session)} ${projectLabel}`.toLowerCase(); + return haystack.includes(query); +}; + +const MobileProjectIcon: React.FC<{ + project: Pick; + size?: 'sm' | 'md'; +}> = ({ project, size = 'md' }) => { + const { currentTheme } = useThemeSystem(); + + const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; + const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] ?? null : null; + + const containerClasses = size === 'sm' ? 'size-6 rounded-md' : 'size-8 rounded-lg'; + const innerClasses = size === 'sm' ? 'size-3.5' : 'size-4'; + const fallbackIcon = ProjectIcon ? ( + + ) : ( + + ); + + return ( + + {project.iconImage ? ( + + ) : fallbackIcon} + + ); +}; + +const ChevronToggle: React.FC<{ expanded: boolean }> = ({ expanded }) => ( + + + +); + +const ActiveDot: React.FC<{ ariaLabel?: string }> = ({ ariaLabel }) => ( + +); + +const NewWorktreeIconButton: React.FC<{ + onClick: () => void; + className?: string; +}> = ({ onClick, className }) => { + const { t } = useI18n(); + const label = t('sessions.sidebar.project.actions.newWorktree'); + + return ( + + ); +}; + +const SessionRow: React.FC<{ + session: Session; + active: boolean; + indent: number; + /** When provided, shown as a small second-line subtitle below the title (e.g. "Project · branch"). */ + contextLabel?: string; + onSelect: () => void; +}> = ({ session, active, indent, contextLabel, onSelect }) => { + const { t } = useI18n(); + const time = formatRelativeShort(getSessionTimestamp(session)); + const title = session.title?.trim() || t('mobile.sessions.untitled'); + return ( +
+ +
+ ); +}; + +const ShowMoreRow: React.FC<{ + remaining: number; + indent: number; + onClick: () => void; +}> = ({ remaining, indent, onClick }) => { + const { t } = useI18n(); + return ( + + ); +}; + +const SortableProjectRow: React.FC<{ + project: ProjectMeta; + totalSessions: number; + isFirst: boolean; + isLast: boolean; + confirmingDelete: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + onRequestRemove: () => void; + onConfirmRemove: () => void; +}> = ({ + project, + totalSessions, + isFirst, + isLast, + confirmingDelete, + onMoveUp, + onMoveDown, + onRequestRemove, + onConfirmRemove, +}) => { + const { t } = useI18n(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: project.id }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 10 : 1, + }; + return ( +
+ + + {project.label} + {confirmingDelete ? ( + + ) : ( + <> + {totalSessions} + + + + )} + +
+ ); +}; + +export const MobileSessionsSheet: React.FC = ({ open, onOpenChange }) => { + const { t } = useI18n(); + const { git } = useRuntimeAPIs(); + const liveSessions = useAllLiveSessions(); + const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); + const projects = useProjectsStore((state) => state.projects); + const activeProjectId = useProjectsStore((state) => state.activeProjectId); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const setActiveProject = useProjectsStore((state) => state.setActiveProject); + const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); + const reorderProjects = useProjectsStore((state) => state.reorderProjects); + const removeProject = useProjectsStore((state) => state.removeProject); + const [query, setQuery] = React.useState(''); + const [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false); + const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); + const [worktreeDialogProjectId, setWorktreeDialogProjectId] = React.useState(null); + const [worktreesByProject, setWorktreesByProject] = React.useState>(new Map()); + const [gitProjectPaths, setGitProjectPaths] = React.useState>(new Set()); + const [editingOrder, setEditingOrder] = React.useState(false); + const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); + // Buckets the user explicitly expanded past the SESSIONS_PER_BUCKET cap. Key: `${projectId}::${bucketKey}`. + const [expandedBucketsAll, setExpandedBucketsAll] = React.useState>(new Set()); + // User overrides: true = user explicitly expanded, false = user explicitly collapsed, + // missing key = use the default rule (active project/worktree expanded). + const [projectOverrides, setProjectOverrides] = React.useState>(new Map()); + const [worktreeOverrides, setWorktreeOverrides] = React.useState>(new Map()); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setProjectOverrides(new Map()); + setWorktreeOverrides(new Map()); + setEditingOrder(false); + setConfirmingDeleteId(null); + setExpandedBucketsAll(new Set()); + return; + } + void refreshGlobalSessions(liveSessions); + // intentionally only on open transition — live overlay handles updates after that + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + React.useEffect(() => { + if (!editingOrder) setConfirmingDeleteId(null); + }, [editingOrder]); + + React.useEffect(() => { + if (!open || projects.length === 0) return; + let cancelled = false; + const run = async () => { + const entries = await Promise.all( + projects.map(async (project) => { + const path = normalizePath(project.path); + if (!path) return null; + const isGitRepo = await git.checkIsGitRepository(path).catch(() => false); + const worktrees = isGitRepo + ? await listProjectWorktrees({ id: project.id, path }).catch(() => []) + : []; + return [path, worktrees, isGitRepo] as const; + }), + ); + if (cancelled) return; + const next = new Map(); + const nextGitProjectPaths = new Set(); + for (const entry of entries) { + if (entry) { + next.set(entry[0], entry[1]); + if (entry[2]) nextGitProjectPaths.add(entry[0]); + } + } + setWorktreesByProject(next); + setGitProjectPaths(nextGitProjectPaths); + }; + void run(); + return () => { + cancelled = true; + }; + }, [git, open, projects]); + + const projectsMeta = React.useMemo( + () => + projects.map((project) => ({ + id: project.id, + label: project.label?.trim() || getProjectLabel(project.path), + path: normalizePath(project.path), + icon: project.icon, + color: project.color, + iconImage: project.iconImage, + iconBackground: project.iconBackground, + isGitRepo: gitProjectPaths.has(normalizePath(project.path)), + worktrees: worktreesByProject.get(normalizePath(project.path)) ?? [], + })), + [gitProjectPaths, projects, worktreesByProject], + ); + + /** + * Global sessions cover all directories — even unbootstrapped ones — so the tree shows + * accurate counts even when a worktree's live store hasn't been hydrated yet. Live + * sessions overlay for fresher data on the active directory. + */ + const sessions = React.useMemo(() => { + const liveById = new Map(liveSessions.map((session) => [session.id, session])); + const merged = globalActiveSessions.map((session) => liveById.get(session.id) ?? session); + const seenIds = new Set(merged.map((session) => session.id)); + for (const session of liveSessions) { + if (!seenIds.has(session.id)) merged.push(session); + } + return merged; + }, [globalActiveSessions, liveSessions]); + + const normalizedQuery = query.trim().toLowerCase(); + + const projectNodes = React.useMemo(() => { + const nodes: ProjectNode[] = projectsMeta.map((project) => ({ + project, + buckets: [] as WorktreeBucket[], + totalSessions: 0, + isActive: project.id === activeProjectId, + })); + + const ensureBucket = (node: ProjectNode, path: string, worktree: WorktreeMetadata | null): WorktreeBucket => { + const normalizedBucketPath = normalizePath(path) || node.project.path; + const key = normalizedBucketPath || '__root__'; + let bucket = node.buckets.find((entry) => entry.key === key); + if (!bucket) { + bucket = { + key, + label: worktree?.branch || getProjectLabel(normalizedBucketPath), + path: normalizedBucketPath, + worktree, + sessions: [], + }; + node.buckets.push(bucket); + } + return bucket; + }; + + for (const node of nodes) { + ensureBucket(node, node.project.path, null); + for (const worktree of node.project.worktrees) ensureBucket(node, worktree.path, worktree); + } + + for (const session of sessions) { + const directory = getSessionDirectory(session); + if (!directory) continue; + const node = nodes.find((entry) => { + if (pathBelongsToRoot(directory, entry.project.path)) return true; + return entry.project.worktrees.some((worktree) => pathBelongsToRoot(directory, worktree.path)); + }); + if (!node) continue; + const matchedWorktree = node.project.worktrees.find((entry) => pathBelongsToRoot(directory, entry.path)); + const bucket = matchedWorktree + ? ensureBucket(node, matchedWorktree.path, matchedWorktree) + : ensureBucket(node, node.project.path, null); + bucket.sessions.push(session); + } + + for (const node of nodes) { + for (const bucket of node.buckets) { + bucket.sessions.sort((a, b) => getSessionTimestamp(b) - getSessionTimestamp(a)); + node.totalSessions += bucket.sessions.length; + } + } + + return nodes; + }, [activeProjectId, projectsMeta, sessions]); + + const normalizedDirectory = normalizePath(currentDirectory); + + const findActiveWorktreePath = (node: ProjectNode): string | null => { + if (!node.isActive) return null; + if (normalizedDirectory === node.project.path) return node.project.path; + const matched = node.project.worktrees.find((entry) => pathBelongsToRoot(normalizedDirectory, entry.path)); + return matched?.path ?? node.project.path; + }; + + // Default rule for a project: active project expanded; non-empty query expands any node + // that contains a matching session. User override (true/false) wins over defaults. + const isProjectExpanded = (node: ProjectNode): boolean => { + const override = projectOverrides.get(node.project.id); + if (override !== undefined) return override; + if (normalizedQuery) { + return node.buckets.some((bucket) => + bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)), + ); + } + return node.isActive; + }; + + const isWorktreeExpanded = (node: ProjectNode, bucket: WorktreeBucket): boolean => { + const key = `${node.project.id}::${bucket.key}`; + const override = worktreeOverrides.get(key); + if (override !== undefined) return override; + if (normalizedQuery) { + return bucket.sessions.some((session) => + sessionMatchesQuery(session, node.project.label, normalizedQuery), + ); + } + return findActiveWorktreePath(node) === bucket.path; + }; + + const toggleProject = (projectId: string, currentlyExpanded: boolean) => { + setProjectOverrides((previous) => { + const next = new Map(previous); + next.set(projectId, !currentlyExpanded); + return next; + }); + }; + + const toggleWorktree = (projectId: string, bucketKey: string, currentlyExpanded: boolean) => { + const key = `${projectId}::${bucketKey}`; + setWorktreeOverrides((previous) => { + const next = new Map(previous); + next.set(key, !currentlyExpanded); + return next; + }); + }; + + const handleSelectSession = (session: Session) => { + void setCurrentSession(session.id, getSessionDirectory(session) || null); + onOpenChange(false); + }; + + const handleStartNewChat = () => { + openNewSessionDraft(); + onOpenChange(false); + }; + + const handleNewWorktree = (projectId: string) => { + setWorktreeDialogProjectId(projectId); + setActiveProjectIdOnly(projectId); + setNewWorktreeDialogOpen(true); + }; + + const dndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const handleReorderDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setConfirmingDeleteId(null); + if (!over || active.id === over.id) return; + const fromIndex = projectsMeta.findIndex((p) => p.id === active.id); + const toIndex = projectsMeta.findIndex((p) => p.id === over.id); + if (fromIndex < 0 || toIndex < 0) return; + reorderProjects(fromIndex, toIndex); + }; + + const handleRequestRemoveProject = (projectId: string) => { + setConfirmingDeleteId((current) => (current === projectId ? null : projectId)); + }; + + const handleConfirmRemoveProject = (project: ProjectMeta) => { + removeProject(project.id); + setConfirmingDeleteId(null); + toast.success(t('mobile.sessions.toast.projectRemoved', { label: project.label })); + }; + + /** Short "Project · branch" string shown under the session title in search results. */ + const buildSessionContextLabel = React.useCallback( + (session: Session): string => { + const directory = getSessionDirectory(session); + const project = projectsMeta.find((entry) => { + if (pathBelongsToRoot(directory, entry.path)) return true; + return entry.worktrees.some((worktree) => pathBelongsToRoot(directory, worktree.path)); + }); + if (!project) return getProjectLabel(directory) || directory; + const matchedWorktree = project.worktrees.find((entry) => pathBelongsToRoot(directory, entry.path)); + if (matchedWorktree?.branch) return `${project.label} · ${matchedWorktree.branch}`; + return project.label; + }, + [projectsMeta], + ); + + const handleSelectProject = (project: ProjectMeta) => { + setActiveProject(project.id); + onOpenChange(false); + }; + + const filteredNodes = React.useMemo(() => { + if (!normalizedQuery) return projectNodes; + return projectNodes.filter((node) => { + if (`${node.project.label} ${node.project.path}`.toLowerCase().includes(normalizedQuery)) return true; + return node.buckets.some((bucket) => + bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)), + ); + }); + }, [normalizedQuery, projectNodes]); + + // Preserve the store's project order. Reorder mode persists changes via + // useProjectsStore.reorderProjects, which writes back to the same source we render here. + const orderedNodes = filteredNodes; + + // Flat lists used only by the dedicated search-results view. + const searchSessionMatches = React.useMemo(() => { + if (!normalizedQuery) return [] as Session[]; + return sessions + .filter((session) => { + const directory = getSessionDirectory(session); + const project = projectsMeta.find((entry) => { + if (pathBelongsToRoot(directory, entry.path)) return true; + return entry.worktrees.some((worktree) => pathBelongsToRoot(directory, worktree.path)); + }); + return sessionMatchesQuery(session, project?.label ?? '', normalizedQuery); + }) + .sort((a, b) => getSessionTimestamp(b) - getSessionTimestamp(a)); + }, [normalizedQuery, projectsMeta, sessions]); + + const searchProjectMatches = React.useMemo(() => { + if (!normalizedQuery) return [] as Array; + return projectsMeta + .filter((project) => `${project.label} ${project.path}`.toLowerCase().includes(normalizedQuery)) + .map((project) => ({ + ...project, + sessionCount: sessions.filter((session) => { + const directory = getSessionDirectory(session); + if (pathBelongsToRoot(directory, project.path)) return true; + return project.worktrees.some((worktree) => pathBelongsToRoot(directory, worktree.path)); + }).length, + })); + }, [normalizedQuery, projectsMeta, sessions]); + + const hasNoMatches = + normalizedQuery && searchSessionMatches.length === 0 && searchProjectMatches.length === 0; + const canEditOrder = !normalizedQuery && projectsMeta.length > 1; + + const editToggle = canEditOrder ? ( + + ) : null; + + const newChatButton = + !editingOrder && projectsMeta.length > 0 ? ( + + ) : null; + + const addProjectButton = !editingOrder ? ( + + ) : null; + + const trailingActions = + newChatButton || addProjectButton || editToggle ? ( + <> + {newChatButton} + {addProjectButton} + {editToggle} + + ) : null; + + return ( + onOpenChange(false)} + ariaLabel={t('mobile.sessions.sheet.title')} + title={t('mobile.sessions.sheet.title')} + trailing={trailingActions} + > +
+
+
+ + setQuery(event.target.value)} + placeholder={t('mobile.sessions.search.placeholder')} + className={cn('h-11 pl-9', query && 'pr-10')} + /> + {query ? ( + + ) : null} +
+
+ + + {projectsMeta.length === 0 ? ( + setDirectoryDialogOpen(true)} + > + + {t('sessions.sidebar.header.actions.addProject')} + + } + /> + ) : hasNoMatches ? ( + + ) : normalizedQuery && !editingOrder ? ( +
+ {searchSessionMatches.length > 0 ? ( +
+
+ + {t('mobile.sessions.search.section.sessions')} + + + {searchSessionMatches.length} + +
+
+ {searchSessionMatches.map((session, index) => ( +
0 && 'border-t border-border/30')}> + handleSelectSession(session)} + /> +
+ ))} +
+
+ ) : null} + + {searchProjectMatches.length > 0 ? ( +
+
+ + {t('mobile.sessions.search.section.projects')} + + + {searchProjectMatches.length} + +
+
+ {searchProjectMatches.map((project, index) => ( +
0 && 'border-t border-border/30')} + > + + {project.isGitRepo ? ( + handleNewWorktree(project.id)} + /> + ) : null} +
+ ))} +
+
+ ) : null} +
+ ) : editingOrder ? ( +
+

+ {t('mobile.sessions.editOrderHint')} +

+ + p.id)} + strategy={verticalListSortingStrategy} + > +
+ {projectsMeta.map((project, index) => { + const node = projectNodes.find((n) => n.project.id === project.id); + return ( + reorderProjects(index, index - 1)} + onMoveDown={() => reorderProjects(index, index + 1)} + onRequestRemove={() => handleRequestRemoveProject(project.id)} + onConfirmRemove={() => handleConfirmRemoveProject(project)} + /> + ); + })} +
+
+
+
+ ) : ( +
+ {orderedNodes.map((node, nodeIndex) => { + const projectExpanded = isProjectExpanded(node); + const buckets = normalizedQuery + ? node.buckets.filter((bucket) => + bucket.sessions.some((session) => + sessionMatchesQuery(session, node.project.label, normalizedQuery), + ), + ) + : node.buckets; + const showWorktreeLevel = node.buckets.length > 1; + // Align session title left edge with the parent label's letters. + // Project label sits at ≈52px; worktree label at ≈68px. SessionRow adds 16px (dot + gap) on top of indent. + const sessionsIndent = showWorktreeLevel ? 52 : 36; + const activeWorktreePath = findActiveWorktreePath(node); + return ( +
0 && 'border-t border-border/30')} + > +
+ + {node.project.isGitRepo ? ( + handleNewWorktree(node.project.id)} + /> + ) : null} +
+ + {projectExpanded ? ( +
+ {showWorktreeLevel + ? buckets.map((bucket) => { + const worktreeExpanded = isWorktreeExpanded(node, bucket); + const isActiveWt = activeWorktreePath === bucket.path; + return ( +
+ + {worktreeExpanded ? ( + (() => { + const bucketKey = `${node.project.id}::${bucket.key}`; + const showAll = Boolean(normalizedQuery) || expandedBucketsAll.has(bucketKey); + const visibleSessions = showAll + ? bucket.sessions + : bucket.sessions.slice(0, SESSIONS_PER_BUCKET); + const remaining = bucket.sessions.length - visibleSessions.length; + return ( +
+ {visibleSessions.map((session) => ( + handleSelectSession(session)} + /> + ))} + {remaining > 0 ? ( + + setExpandedBucketsAll((previous) => { + const next = new Set(previous); + next.add(bucketKey); + return next; + }) + } + /> + ) : null} +
+ ); + })() + ) : null} +
+ ); + }) + : (() => { + const bucket = buckets[0]; + if (!bucket) return null; + const bucketKey = `${node.project.id}::${bucket.key}`; + const showAll = Boolean(normalizedQuery) || expandedBucketsAll.has(bucketKey); + const visibleSessions = showAll + ? bucket.sessions + : bucket.sessions.slice(0, SESSIONS_PER_BUCKET); + const remaining = bucket.sessions.length - visibleSessions.length; + return ( +
+ {visibleSessions.map((session) => ( + handleSelectSession(session)} + /> + ))} + {remaining > 0 ? ( + + setExpandedBucketsAll((previous) => { + const next = new Set(previous); + next.add(bucketKey); + return next; + }) + } + /> + ) : null} +
+ ); + })()} +
+ ) : null} +
+ ); + })} +
+ )} +
+ + + { + setNewWorktreeDialogOpen(value); + if (!value) setWorktreeDialogProjectId(null); + }} + onWorktreeCreated={(worktreePath, options) => { + if (options?.sessionId) void setCurrentSession(options.sessionId, worktreePath); + else + openNewSessionDraft({ + selectedProjectId: worktreeDialogProjectId, + directoryOverride: worktreePath, + preserveDirectoryOverride: true, + }); + onOpenChange(false); + }} + /> +
+
+ ); +}; + +const MobileSessionsEmpty: React.FC<{ + title: string; + description?: string; + action?: React.ReactNode; +}> = ({ title, description, action }) => ( +
+

{title}

+ {description ?

{description}

: null} + {action ?
{action}
: null} +
+); diff --git a/packages/ui/src/apps/MobileSurfaceShell.tsx b/packages/ui/src/apps/MobileSurfaceShell.tsx new file mode 100644 index 00000000..20da21b1 --- /dev/null +++ b/packages/ui/src/apps/MobileSurfaceShell.tsx @@ -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 = ({ + open, + onClose, + title, + subtitle, + trailing, + onBack, + disableSwipeDismiss = false, + headerless = false, + ariaLabel, + children, +}) => { + const { t } = useI18n(); + const rootRef = React.useRef(null); + const [mounted, setMounted] = React.useState(false); + const [entered, setEntered] = React.useState(false); + const [dragOffset, setDragOffset] = React.useState(0); + const dragStartYRef = React.useRef(null); + const isDraggingRef = React.useRef(false); + const surfaceRef = React.useRef(null); + const previousFocusRef = React.useRef(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( + '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( + '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) => { + if (disableSwipeDismiss) return; + dragStartYRef.current = event.touches[0]?.clientY ?? null; + isDraggingRef.current = true; + }; + + const handleDragMove = (event: React.TouchEvent) => { + 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 ? ( + + ) : ( + + ); + + const visualTransform = entered + ? `translateY(${dragOffset}px)` + : 'translateY(100%)'; + + return createPortal( +
+
, + rootRef.current, + ); +}; diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index b3c74248..d0006eb2 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -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; diff --git a/packages/ui/src/apps/mobileAppContext.tsx b/packages/ui/src/apps/mobileAppContext.tsx new file mode 100644 index 00000000..6689ce74 --- /dev/null +++ b/packages/ui/src/apps/mobileAppContext.tsx @@ -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(null); + +export const DedicatedMobileAppProvider: React.FC<{ + actions: MobileAppActions; + children: React.ReactNode; +}> = ({ actions, children }) => ( + {children} +); + +/** + * 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); diff --git a/packages/ui/src/apps/renderMobileApp.tsx b/packages/ui/src/apps/renderMobileApp.tsx new file mode 100644 index 00000000..d5d33495 --- /dev/null +++ b/packages/ui/src/apps/renderMobileApp.tsx @@ -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( + + + + + + + + + + + + + , + ); +} diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 9efed1fd..43037233 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -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 => { - 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 => { - 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 => { + 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 => { + if (!isDesktopShell() || typeof window === 'undefined') { + return ''; + } + const invoke = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record) => Promise } } }).__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 => { + 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 => { + 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(() => { return { @@ -268,6 +397,21 @@ export const SessionAuthGate: React.FC = ({ 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 = ({ 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 = ({ 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'); diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 641637ee..b315c94f 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -142,10 +142,8 @@ type ChatViewportProps = { stickyUserHeader: boolean; scrollRef: React.RefObject; messageListRef: React.RefObject; - 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(({ @@ -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 = ({ autoOpenDraft = tr isPinned, showScrollButton, }); - const { loadEarlier } = timelineController; - const resumeToLatestInstant = React.useCallback(() => { goToBottom('instant'); }, [goToBottom]); @@ -662,10 +648,6 @@ export const ChatContainer: React.FC = ({ 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 = ({ 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 = ({ autoOpenDraft = tr retryOverlay={retryOverlay} handleMessageContentChange={handleMessageContentChange} getAnimationHandlers={getAnimationHandlers} - handleLoadOlder={handleLoadOlder} handleHistoryScroll={timelineController.handleHistoryScroll} scrollToBottom={resumeToLatestInstant} sessionQuestions={sessionQuestions} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index a4002bc4..ee0f63fb 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 ? ( + + ) : ( + + ); return ( - {imageUrl ? ( + {project.iconImage ? ( - + - ) : projectIconName ? ( - - ) : ( - - )} + ) : fallbackIcon} {getProjectDisplayLabel(project)} ); @@ -4426,6 +4435,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo <>
+ = ({ onOpenSettings, scrollTo )}
- {/* Mobile Session Status Bar - above input */} + {/* Mobile session panel: slide-up overlay toggled by MobileSessionPanelTrigger. */} {isMobile && }
diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 53d84189..0c8f9e54 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -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(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; diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 0dff058d..577995b0 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -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 => { const request = new Promise((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', }) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 121f6910..e8762c55 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -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; } @@ -1101,7 +1098,6 @@ StreamingTailContent.displayName = 'StreamingTailContent'; const MessageList = React.forwardRef(({ sessionKey, - turnStart, disableStaging = false, messages, sessionIsWorking = false, @@ -1110,9 +1106,7 @@ const MessageList = React.forwardRef(({ retryOverlay = null, onMessageContentChange, getAnimationHandlers, - hasMoreAbove, isLoadingOlder, - onLoadOlder, scrollToBottom, scrollRef, }, ref) => { @@ -1128,7 +1122,6 @@ const MessageList = React.forwardRef(({ animatedIds: Set; }>({ 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(({ return (
- {(turnStart > 0 || hasMoreAbove) && ( -
- {isLoadingOlder ? ( - - Loading… - - ) : ( - - )} -
- )} -
void; @@ -64,6 +30,28 @@ interface SessionWithStatus extends Session { _childIndicators?: Array<{ session: Session; isRunning: boolean }>; } +// Cross-project session source. Mirrors the dedicated MobileSessionsSheet: +// global sessions cover all directories (even unbootstrapped ones), while the +// live aggregate (`useAllLiveSessions`) surfaces fresher data and every +// bootstrapped directory. Merging both makes other projects' sessions appear. +function useAllProjectSessions(): Session[] { + const liveSessions = useAllLiveSessions(); + const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); + return React.useMemo(() => { + const liveById = new Map(liveSessions.map((session) => [session.id, session])); + const merged = globalActiveSessions.map((session) => liveById.get(session.id) ?? session); + const seen = new Set(merged.map((session) => session.id)); + for (const session of liveSessions) { + if (!seen.has(session.id)) merged.push(session); + } + return merged; + }, [globalActiveSessions, liveSessions]); +} + +// Max sessions shown per (filtered) project list - a "recent" cap applied +// after filtering, so each project view shows at most this many. +const MAX_RECENT_SESSIONS = 25; + // Normalize path for comparison const normalize = (value: string): string => { if (!value) return ''; @@ -71,61 +59,21 @@ const normalize = (value: string): string => { return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); }; -const getDisplaySessionTitle = (session: Session): string => { - const title = session.title; - if (title && title.trim()) return title; - return 'New session'; -}; - -const countUnreadSessions = (unseenCounts: Record): number => { - let count = 0; - for (const value of Object.values(unseenCounts)) { - if (value > 0) count += 1; - } - return count; -}; - -function useCurrentContextUsage(): SessionContextUsage | null { - const getContextUsage = useSessionUIStore((state) => state.getContextUsage); - const getCurrentModel = useConfigStore((state) => state.getCurrentModel); - - const currentModel = getCurrentModel(); - const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null - ? (currentModel.limit as Record) - : null; - const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0); - const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0); - return getContextUsage(contextLimit, outputLimit); -} - -function useCurrentProjectDisplay() { - const { currentTheme } = useThemeSystem(); - const projects = useProjectsStore((state) => state.projects); - const activeProjectId = useProjectsStore((state) => state.activeProjectId); - const homeDirectory = useDirectoryStore((state) => state.homeDirectory); - const activeProject = React.useMemo( - () => projects.find((project) => project.id === activeProjectId) ?? null, - [activeProjectId, projects], - ); - - const currentProjectIconImageUrl = activeProject - ? getProjectIconImageUrl(activeProject, { - themeVariant: currentTheme.metadata.variant, - iconColor: currentTheme.colors.surface.foreground, - }) - : null; - - return { - projects, - activeProjectId, - homeDirectory, - currentProjectLabel: activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory), - currentProjectIcon: activeProject?.icon, - currentProjectIconImageUrl, - currentProjectIconBackground: activeProject?.iconBackground ?? null, - currentProjectColor: activeProject?.color, +// A session's directory, mirroring the store's canonical resolution. +const sessionDirectory = (session: Session): string => { + const record = session as Session & { + directory?: string | null; + project?: { worktree?: string | null } | null; }; -} + return normalize(record.directory ?? record.project?.worktree ?? ''); +}; + +// Prefix-match used to group a session under a project root or worktree. +const pathBelongsToRoot = (path: string, root: string): boolean => { + const p = normalize(path); + const r = normalize(root); + return Boolean(p && r && (p === r || p.startsWith(`${r}/`))); +}; function useSessionGrouping( sessions: Session[], @@ -224,10 +172,7 @@ function useSessionGrouping( return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length }; } -function useSessionHelpers( - agents: Array<{ name: string }>, - sessionStatus: Record | undefined -) { +function useSessionHelpers(agents: Array<{ name: string }>) { const getSessionAgentName = React.useCallback((session: Session): string => { const agent = (session as { agent?: string }).agent; if (agent) return agent; @@ -239,23 +184,20 @@ function useSessionHelpers( }, [agents]); const getSessionTitle = React.useCallback((session: Session): string => { - return getDisplaySessionTitle(session); + const title = session.title; + if (title && title.trim()) return title; + return 'New session'; }, []); - const isRunning = React.useCallback((sessionId: string): boolean => { - const status = sessionStatus?.[sessionId]; - return status?.type === 'busy' || status?.type === 'retry'; - }, [sessionStatus]); - const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); const needsAttention = React.useCallback((sessionId: string): boolean => { return (unseenCounts[sessionId] ?? 0) > 0; }, [unseenCounts]); - return { getSessionAgentName, getSessionTitle, isRunning, needsAttention }; + return { getSessionAgentName, getSessionTitle, needsAttention }; } -// Hook to calculate project status indicators +// Per-project status indicators (running / unread) for the filter chips. function useProjectStatus( sessions: Session[], sessionStatus: Record | undefined, @@ -265,7 +207,7 @@ function useProjectStatus( const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory); const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount); - const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => { + return React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => { const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => { const status = sessionStatus?.[sessionId]; if (status?.type === 'busy' || status?.type === 'retry') return status.type; @@ -273,9 +215,7 @@ function useProjectStatus( }; const projectRoot = normalize(projectPath); - if (!projectRoot) { - return { hasRunning: false, hasUnread: false }; - } + if (!projectRoot) return { hasRunning: false, hasUnread: false }; const dirs: string[] = [projectRoot]; const worktrees = availableWorktreesByProject.get(projectRoot) ?? []; @@ -283,9 +223,7 @@ function useProjectStatus( const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null; if (typeof p === 'string' && p.trim()) { const normalized = normalize(p); - if (normalized && normalized !== projectRoot) { - dirs.push(normalized); - } + if (normalized && normalized !== projectRoot) dirs.push(normalized); } } @@ -294,45 +232,49 @@ function useProjectStatus( let hasUnread = false; for (const dir of dirs) { - const list = getSessionsByDirectory(dir); - for (const session of list) { - if (!session?.id || seen.has(session.id)) { - continue; - } + for (const session of getSessionsByDirectory(dir)) { + if (!session?.id || seen.has(session.id)) continue; seen.add(session.id); - const statusType = getStatusType(session.id); - if (statusType === 'busy' || statusType === 'retry') { - hasRunning = true; - } - - if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) { - hasUnread = true; - } - - if (hasRunning && hasUnread) { - break; - } - } - if (hasRunning && hasUnread) { - break; + if (getStatusType(session.id) !== 'idle') hasRunning = true; + if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) hasUnread = true; + if (hasRunning && hasUnread) break; } + if (hasRunning && hasUnread) break; } return { hasRunning, hasUnread }; }, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]); +} - return projectStatusMap; +// Resolves the project's root directories (root + known worktrees) for +// prefix-matching sessions, mirroring the dedicated MobileSessionsSheet. +function useProjectRootsResolver() { + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + + return React.useCallback((project: ProjectEntry): string[] => { + const projectRoot = normalize(project.path); + const roots = [projectRoot]; + const worktrees = availableWorktreesByProject.get(projectRoot) ?? []; + for (const meta of worktrees) { + const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null; + if (typeof p === 'string' && p.trim()) { + const normalized = normalize(p); + if (normalized) roots.push(normalized); + } + } + return roots; + }, [availableWorktreesByProject]); } function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) { if (isRunning) { - return ; + return ; } if (needsAttention) { - return
; + return
; } - return
; + return
; } function RunningIndicator({ count }: { count: number }) { @@ -355,159 +297,6 @@ function UnreadIndicator({ count }: { count: number }) { ); } -function SessionItem({ - session, - isCurrent, - getSessionAgentName, - getSessionTitle, - onClick, - onDoubleClick, - needsAttention, - isEditing = false, - editingTitle = '', - onEditingTitleChange, - onEditSave, - onEditCancel, -}: { - session: SessionWithStatus; - isCurrent: boolean; - getSessionAgentName: (s: Session) => string; - getSessionTitle: (s: Session) => string; - onClick: () => void; - onDoubleClick?: (sessionId: string, sessionTitle: string) => void; - needsAttention: (sessionId: string) => boolean; - isEditing?: boolean; - editingTitle?: string; - onEditingTitleChange?: (value: string) => void; - onEditSave?: () => void; - onEditCancel?: () => void; -}) { - const agentName = getSessionAgentName(session); - const agentColor = getAgentColor(agentName); - const extraCount = (session._runningChildrenCount || 0) + (session._statusType !== 'idle' ? 1 : 0) - 1 - (session._childIndicators?.length || 0); - const sessionTitle = getSessionTitle(session); - const editInputRef = React.useRef(null); - const editCancelledRef = React.useRef(false); - - React.useEffect(() => { - if (isEditing) { - editCancelledRef.current = false; - const node = editInputRef.current; - if (node) { - node.focus(); - node.select(); - } - } - }, [isEditing]); - - return ( -
{ - if (isEditing) return; - e.stopPropagation(); - onDoubleClick?.(session.id, sessionTitle); - }} - onKeyDown={(e) => { - if (isEditing) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} - className={cn( - "flex items-center gap-0.5 px-1.5 py-px text-left transition-colors", - "hover:bg-[var(--interactive-hover)] active:bg-[var(--interactive-selection)]", - isCurrent && "bg-[var(--interactive-selection)]/30" - )} - > -
- -
- -
- - {isEditing ? ( - onEditingTitleChange?.(e.target.value)} - onClick={(e) => e.stopPropagation()} - onDoubleClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - onEditSave?.(); - } else if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - editCancelledRef.current = true; - onEditCancel?.(); - } - }} - onBlur={() => { - if (editCancelledRef.current) { - editCancelledRef.current = false; - return; - } - onEditSave?.(); - }} - className={cn( - "flex-1 min-w-0 text-[13px] leading-tight px-1 py-px rounded", - "bg-background border border-[var(--interactive-border)]", - "text-[var(--surface-foreground)] outline-none", - "focus:border-[var(--primary-base)]" - )} - /> - ) : ( - - {sessionTitle} - - )} - - {(session._childIndicators?.length || 0) > 0 && ( -
- [ -
- {session._childIndicators!.map(({ session: child }) => { - const childColor = getAgentColor(getSessionAgentName(child)); - return ( -
- -
- ); - })} - {extraCount > 0 && ( - - +{extraCount} - - )} -
- ] -
- )} -
- ); -} - function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsage | null }) { if (!contextUsage || contextUsage.totalTokens === 0) return null; @@ -523,1412 +312,348 @@ function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsa ); } -interface SessionStatusHeaderProps { - currentSessionId?: string | null; - currentSessionTitle: string; - currentProjectLabel?: string; - currentProjectIcon?: string | null; - currentProjectIconImageUrl?: string | null; - currentProjectIconBackground?: string | null; - currentProjectColor?: string | null; - onToggle: () => void; - isExpanded?: boolean; - childIndicators?: Array<{ session: Session; isRunning: boolean }>; - isEditing?: boolean; - editingTitle?: string; - onTitleDoubleClick?: (sessionId: string, sessionTitle: string) => void; - onEditingTitleChange?: (value: string) => void; - onEditSave?: () => void; - onEditCancel?: () => void; -} - -function SessionStatusHeader({ - currentSessionId, - currentSessionTitle, - currentProjectLabel, - currentProjectIcon, - currentProjectIconImageUrl, - currentProjectIconBackground, - currentProjectColor, - onToggle, - isExpanded = false, - childIndicators = [], - isEditing = false, - editingTitle = '', - onTitleDoubleClick, - onEditingTitleChange, - onEditSave, - onEditCancel, -}: SessionStatusHeaderProps) { - const [imageFailed, setImageFailed] = React.useState(false); - const projectIconName = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null; - const imageUrl = !imageFailed ? currentProjectIconImageUrl : null; - const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null; - const extraCount = childIndicators.length > 3 ? childIndicators.length - 3 : 0; - const editInputRef = React.useRef(null); - const editCancelledRef = React.useRef(false); - - React.useEffect(() => { - setImageFailed(false); - }, [currentProjectIconImageUrl]); - - React.useEffect(() => { - if (isEditing) { - editCancelledRef.current = false; - const node = editInputRef.current; - if (node) { - node.focus(); - node.select(); - } - } - }, [isEditing]); - - return ( -
{ - if (isEditing) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggle(); - } - }} - className="w-full flex flex-col px-2 py-0.5 text-left transition-colors hover:bg-[var(--interactive-hover)]" - > - {!isExpanded && currentProjectLabel && ( -
-
- {imageUrl ? ( - - setImageFailed(true)} - /> - - ) : projectIconName && ( - - )} - - {currentProjectLabel} - -
-
-
- )} -
- {isEditing ? ( - onEditingTitleChange?.(e.target.value)} - onClick={(e) => e.stopPropagation()} - onDoubleClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - onEditSave?.(); - } else if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - editCancelledRef.current = true; - onEditCancel?.(); - } - }} - onBlur={() => { - if (editCancelledRef.current) { - editCancelledRef.current = false; - return; - } - onEditSave?.(); - }} - className={cn( - "flex-1 min-w-0 text-[13px] leading-none px-1 py-0.5 rounded", - "bg-background border border-[var(--interactive-border)]", - "text-[var(--surface-foreground)] outline-none", - "focus:border-[var(--primary-base)]" - )} - /> - ) : ( - { - if (!currentSessionId || !onTitleDoubleClick) return; - e.preventDefault(); - e.stopPropagation(); - onTitleDoubleClick(currentSessionId, currentSessionTitle); - }} - > - {currentSessionTitle} - - )} - {childIndicators.length > 0 && ( -
- [ -
- {childIndicators.slice(0, 3).map((child) => { - const childAgent = (child.session as { agent?: string }).agent || 'agent'; - const childColor = getAgentColor(childAgent); - return ( -
- -
- ); - })} - {extraCount > 0 && ( - - +{extraCount} - - )} -
- ] -
- )} -
-
- ); -} - -// Hook for long press with movement detection -function useLongPress( - onLongPress: () => void, - onClick: () => void, - ms = 500 -) { - const timerRef = React.useRef(null); - const isLongPress = React.useRef(false); - const startPosRef = React.useRef<{ x: number; y: number } | null>(null); - const hasMovedRef = React.useRef(false); - const MOVE_THRESHOLD = 10; // pixels - - const start = React.useCallback((clientX: number, clientY: number) => { - isLongPress.current = false; - hasMovedRef.current = false; - startPosRef.current = { x: clientX, y: clientY }; - timerRef.current = setTimeout(() => { - if (!hasMovedRef.current) { - isLongPress.current = true; - onLongPress(); - } - }, ms); - }, [onLongPress, ms]); - - const move = React.useCallback((clientX: number, clientY: number) => { - if (!startPosRef.current) return; - - const dx = Math.abs(clientX - startPosRef.current.x); - const dy = Math.abs(clientY - startPosRef.current.y); - - if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) { - hasMovedRef.current = true; - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - } - }, []); - - const end = React.useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - startPosRef.current = null; - }, []); - - const handleClick = React.useCallback(() => { - if (!isLongPress.current) { - onClick(); - } - }, [onClick]); - - return { - onMouseDown: (e: React.MouseEvent) => start(e.clientX, e.clientY), - onMouseUp: end, - onMouseLeave: end, - onMouseMove: (e: React.MouseEvent) => move(e.clientX, e.clientY), - onTouchStart: (e: React.TouchEvent) => { - const touch = e.touches[0]; - start(touch.clientX, touch.clientY); - }, - onTouchMove: (e: React.TouchEvent) => { - const touch = e.touches[0]; - move(touch.clientX, touch.clientY); - }, - onTouchEnd: end, - onClick: handleClick, - }; -} - -// Sortable project item for edit panel -interface SortableProjectItemProps { - project: ProjectEntry; - isFirst: boolean; - isLast: boolean; - onMoveUp: () => void; - onMoveDown: () => void; - onEdit: () => void; - onDelete: () => void; - formatProjectLabel: (project: ProjectEntry) => string; -} - -function SortableProjectItem({ - project, - isFirst, - isLast, - onMoveUp, - onMoveDown, - onEdit, - onDelete, - formatProjectLabel, -}: SortableProjectItemProps) { - const { currentTheme } = useThemeSystem(); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: project.id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 10 : 1, - }; - - const [imageFailed, setImageFailed] = React.useState(false); - const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; - const projectIconImageUrl = !imageFailed - ? getProjectIconImageUrl(project, { - themeVariant: currentTheme.metadata.variant, - iconColor: currentTheme.colors.surface.foreground, - }) - : null; - const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; - - return ( -
- {/* Drag handle */} - - - {/* Project info */} -
- {projectIconImageUrl ? ( - - setImageFailed(true)} - /> - - ) : projectIconName ? ( - - ) : ( -
- )} - - {formatProjectLabel(project)} - -
- - {/* Actions */} -
- {/* Move up/down buttons (for non-drag sorting) */} - - - -
- - {/* Edit button */} - - - {/* Delete button */} - -
-
- ); -} - -// Project edit panel for mobile -interface ProjectEditPanelProps { - isOpen: boolean; - onClose: () => void; - projects: ProjectEntry[]; - onReorder: (fromIndex: number, toIndex: number) => void; - onEdit: (project: ProjectEntry) => void; - onDelete: (project: ProjectEntry) => void; - homeDirectory: string | null; -} - -function ProjectEditPanel({ - isOpen, - onClose, - projects, - onReorder, - onEdit, - onDelete, - homeDirectory, -}: ProjectEditPanelProps) { - const { t } = useI18n(); - const [localProjects, setLocalProjects] = React.useState(projects); - - React.useEffect(() => { - setLocalProjects(projects); - }, [projects, isOpen]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (over && active.id !== over.id) { - const oldIndex = localProjects.findIndex((p) => p.id === active.id); - const newIndex = localProjects.findIndex((p) => p.id === over.id); - - setLocalProjects((items) => arrayMove(items, oldIndex, newIndex)); - onReorder(oldIndex, newIndex); - } - }; - - const handleMoveUp = (index: number) => { - if (index > 0) { - setLocalProjects((items) => arrayMove(items, index, index - 1)); - onReorder(index, index - 1); - } - }; - - const handleMoveDown = (index: number) => { - if (index < localProjects.length - 1) { - setLocalProjects((items) => arrayMove(items, index, index + 1)); - onReorder(index, index + 1); - } - }; - - const formatProjectLabel = (project: ProjectEntry): string => { - return project.label?.trim() - || formatDirectoryName(project.path, homeDirectory) - || project.path; - }; - - return ( - - {t('chat.mobileStatus.editProjects.footer')} -

- } - > -
- - p.id)} - strategy={verticalListSortingStrategy} - > - {localProjects.map((project, index) => ( - handleMoveUp(index)} - onMoveDown={() => handleMoveDown(index)} - onEdit={() => onEdit(project)} - onDelete={() => onDelete(project)} - formatProjectLabel={formatProjectLabel} - /> - ))} - - - - {localProjects.length === 0 && ( -
- {t('chat.mobileStatus.editProjects.empty')} -
- )} -
-
- ); -} - -// Project button component with long press support -interface ProjectButtonProps { - project: ProjectEntry; - isActive: boolean; - status: { hasRunning: boolean; hasUnread: boolean }; - projectColorVar: string | null; - onProjectSwitch: () => void; - onOpenEditPanel?: () => void; - formatProjectLabel: (project: ProjectEntry) => string; -} - -function ProjectButton({ - project, - isActive, - status, - projectColorVar, - onProjectSwitch, - onOpenEditPanel, - formatProjectLabel, -}: ProjectButtonProps) { - const { currentTheme } = useThemeSystem(); - const [imageFailed, setImageFailed] = React.useState(false); - const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; - const projectIconImageUrl = !imageFailed - ? getProjectIconImageUrl(project, { - themeVariant: currentTheme.metadata.variant, - iconColor: currentTheme.colors.surface.foreground, - }) - : null; - - React.useEffect(() => { - setImageFailed(false); - }, [project.id, project.iconImage?.updatedAt]); - - const longPressHandlers = useLongPress( - () => { - if (onOpenEditPanel) { - onOpenEditPanel(); - } - }, - onProjectSwitch, - 600 - ); +// A single session row sized for comfortable touch. +function SessionItem({ + session, + isCurrent, + getSessionAgentName, + getSessionTitle, + onClick, + needsAttention, +}: { + session: SessionWithStatus; + isCurrent: boolean; + getSessionAgentName: (s: Session) => string; + getSessionTitle: (s: Session) => string; + onClick: () => void; + needsAttention: (sessionId: string) => boolean; +}) { + const agentName = getSessionAgentName(session); + const agentColor = getAgentColor(agentName); + const attention = needsAttention(session.id); return ( ); } -// Project bar component for expanded view -interface ProjectBarProps { - projects: ProjectEntry[]; - activeProjectId: string | null; - getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean }; - onProjectSwitch: (projectId: string) => void; - onAddProject: () => void; - onRemoveProject?: (projectId: string) => void; - homeDirectory: string | null; +// A project filter pill sized for touch. Selecting it filters +// the session list; it does NOT switch the active project. +interface ProjectFilterChipProps { + label: string; + icon?: string | null; + project?: Pick | null; + iconOptions?: React.ComponentProps['options']; + iconBackground?: string | null; + colorVar?: string | null; + isActive: boolean; + status?: { hasRunning: boolean; hasUnread: boolean }; + onClick: () => void; } -function ProjectBar({ - projects, - activeProjectId, - getProjectStatus, - onProjectSwitch, - onAddProject, - onRemoveProject, - homeDirectory -}: ProjectBarProps) { - const { t } = useI18n(); - const scrollRef = React.useRef(null); - const [editPanelOpen, setEditPanelOpen] = React.useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); - const [projectToDelete, setProjectToDelete] = React.useState(null); - const reorderProjects = useProjectsStore((state) => state.reorderProjects); - - // Scroll active project into view - React.useEffect(() => { - if (scrollRef.current && activeProjectId) { - const activeElement = scrollRef.current.querySelector(`[data-project-id="${activeProjectId}"]`); - if (activeElement) { - activeElement.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); - } - } - }, [activeProjectId]); - - const handleOpenEditPanel = () => { - setEditPanelOpen(true); - }; - - const handleReorder = (fromIndex: number, toIndex: number) => { - reorderProjects(fromIndex, toIndex); - }; - - const [editingProject, setEditingProject] = React.useState(null); - const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta); - - const handleEditProject = (project: ProjectEntry) => { - setEditingProject(project); - }; - - const handleSaveProjectEdit = (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => { - if (editingProject) { - updateProjectMeta(editingProject.id, data); - } - setEditingProject(null); - }; - - const handleDeleteProject = (project: ProjectEntry) => { - setProjectToDelete(project); - setDeleteDialogOpen(true); - }; - - const handleConfirmDelete = () => { - if (projectToDelete && onRemoveProject) { - onRemoveProject(projectToDelete.id); - } - setDeleteDialogOpen(false); - setProjectToDelete(null); - }; - - if (projects.length === 0) { - return ( -
- {t('chat.mobileStatus.projects.empty')} - -
- ); - } - - const formatProjectLabel = (project: ProjectEntry): string => { - return project.label?.trim() - || formatDirectoryName(project.path, homeDirectory) - || project.path; - }; - - // Handle touch events to prevent drawer swipe when scrolling project bar - const handleTouchStart = (e: React.TouchEvent) => { - // Store initial touch position for this component - (e.currentTarget as HTMLElement).dataset.touchStartX = String(e.touches[0].clientX); - (e.currentTarget as HTMLElement).dataset.touchStartY = String(e.touches[0].clientY); - }; - - const handleTouchMove = (e: React.TouchEvent) => { - const target = e.currentTarget as HTMLElement; - const startX = Number(target.dataset.touchStartX || 0); - const startY = Number(target.dataset.touchStartY || 0); - const deltaX = e.touches[0].clientX - startX; - const deltaY = e.touches[0].clientY - startY; - - // If horizontal scroll dominates, prevent default to stop drawer gesture - if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 5) { - e.stopPropagation(); - } - }; - - const handleTouchEnd = (e: React.TouchEvent) => { - // Clean up - const target = e.currentTarget as HTMLElement; - delete target.dataset.touchStartX; - delete target.dataset.touchStartY; - }; +function ProjectFilterChip({ + label, + icon, + project, + iconOptions, + iconBackground, + colorVar, + isActive, + status, + onClick, +}: ProjectFilterChipProps) { + const projectIconName = icon ? PROJECT_ICON_MAP[icon] : null; + const fallbackIcon = projectIconName ? ( + + ) : null; return ( -
-
- {projects.map((project) => { - const isActive = project.id === activeProjectId; - const status = getProjectStatus(project.path); - const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; - - return ( - onProjectSwitch(project.id)} - onOpenEditPanel={handleOpenEditPanel} - formatProjectLabel={formatProjectLabel} - /> - ); - })} -
- - {/* Add project button */} - - - {/* Delete confirmation dialog */} - - - - {t('chat.mobileStatus.projects.removeTitle')} - - {t('chat.mobileStatus.projects.removeDescriptionPrefix')} {projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}? - - - - - - - - - - {/* Project edit panel */} - setEditPanelOpen(false)} - projects={projects} - onReorder={handleReorder} - onEdit={handleEditProject} - onDelete={handleDeleteProject} - homeDirectory={homeDirectory} - /> - - {/* Project edit dialog */} - {editingProject && ( - { - if (!open) setEditingProject(null); - }} - projectId={editingProject.id} - projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)} - projectPath={editingProject.path} - initialIcon={editingProject.icon} - initialColor={editingProject.color} - initialIconBackground={editingProject.iconBackground} - onSave={handleSaveProjectEdit} - /> + - -
-
- ); -} - -function ExpandedView({ - sessions, - currentSessionId, - runningCount, - unreadCount, - currentSessionTitle, - currentProjectLabel, - currentProjectIcon, - currentProjectIconImageUrl, - currentProjectIconBackground, - currentProjectColor, - isExpanded, - onToggleCollapse, - onNewSession, - onSessionClick, - onSessionDoubleClick, - onProjectSwitch, - onAddProject, - onRemoveProject, - getSessionAgentName, - getSessionTitle, - needsAttention, - contextUsage, - projects, - activeProjectId, - getProjectStatus, - homeDirectory, - childIndicators = [], - editingSessionId = null, - editingTitle = '', - onEditingTitleChange, - onEditSave, - onEditCancel, -}: { - sessions: SessionWithStatus[]; - currentSessionId: string; - runningCount: number; - unreadCount: number; - currentSessionTitle: string; - currentProjectLabel?: string; - currentProjectIcon?: string | null; - currentProjectIconImageUrl?: string | null; - currentProjectIconBackground?: string | null; - currentProjectColor?: string | null; - isExpanded: boolean; - onToggleCollapse: () => void; - onNewSession: () => void; - onSessionClick: (id: string) => void; - onSessionDoubleClick?: (sessionId: string, sessionTitle: string) => void; - onProjectSwitch: (projectId: string) => void; - onAddProject: () => void; - onRemoveProject?: (projectId: string) => void; - getSessionAgentName: (s: Session) => string; - getSessionTitle: (s: Session) => string; - needsAttention: (sessionId: string) => boolean; - contextUsage: SessionContextUsage | null; - projects: ProjectEntry[]; - activeProjectId: string | null; - getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean }; - homeDirectory: string | null; - childIndicators?: Array<{ session: Session; isRunning: boolean }>; - editingSessionId?: string | null; - editingTitle?: string; - onEditingTitleChange?: (value: string) => void; - onEditSave?: () => void; - onEditCancel?: () => void; -}) { - const { t } = useI18n(); - const containerRef = React.useRef(null); - const [collapsedHeight, setCollapsedHeight] = React.useState(null); - const [hasMeasured, setHasMeasured] = React.useState(false); - const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); - const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); - - React.useEffect(() => { - if (containerRef.current && !hasMeasured && !isExpanded) { - setCollapsedHeight(containerRef.current.offsetHeight); - setHasMeasured(true); - } - }, [hasMeasured, isExpanded]); - - // Filter sessions by active project - const filteredSessions = React.useMemo(() => { - if (!activeProjectId) return sessions; - - const activeProject = projects.find(p => p.id === activeProjectId); - if (!activeProject) return sessions; - - const projectRoot = normalize(activeProject.path); - const projectDirs = new Set([projectRoot]); - - // Add worktrees - const worktrees = availableWorktreesByProject.get(projectRoot) ?? []; - for (const meta of worktrees) { - const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null; - if (typeof p === 'string' && p.trim()) { - const normalized = normalize(p); - if (normalized) projectDirs.add(normalized); - } - } - - return sessions.filter(session => { - const sessionDir = normalize((session as { directory?: string | null }).directory ?? ''); - return projectDirs.has(sessionDir); - }); - }, [sessions, activeProjectId, projects, availableWorktreesByProject]); - - const previewHeight = collapsedHeight ?? undefined; - const displaySessions = hasMeasured || isExpanded - ? filteredSessions.filter(s => s.id !== currentSessionId) - : filteredSessions.slice(0, 3); - - return ( -
- {/* Header row */} -
-
- -
-
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggleCollapse(); - } - }} - > - - - - -
-
+ + ) : fallbackIcon} - {/* Project switcher bar */} - - - {/* Sessions list */} -
- {displaySessions.length === 0 ? ( -
- {t('chat.mobileStatus.noSessionsInProject')} -
- ) : ( - displaySessions.map((session) => { - // When the current session is being edited, the sticky header - // already renders the rename input; suppress the duplicate - // input on this row to avoid two simultaneous editors. - const isCurrent = session.id === currentSessionId; - const isEditingHere = editingSessionId === session.id && !isCurrent; - return ( - onSessionClick(session.id)} - onDoubleClick={onSessionDoubleClick} - needsAttention={needsAttention} - isEditing={isEditingHere} - editingTitle={editingTitle} - onEditingTitleChange={onEditingTitleChange} - onEditSave={onEditSave} - onEditCancel={onEditCancel} - /> - ); - }) - )} -
-
+ {label} + ); } -const MobileSessionStatusBarCollapsed: React.FC<{ onExpand: () => void }> = ({ - onExpand, +// The chip that lives in the composer footer and toggles the slide-up sheet. +// This is the only persistent affordance; there is no longer a permanent bar. +interface MobileSessionPanelTriggerProps { + footerIconButtonClass: string; + iconSizeClass: string; +} + +export const MobileSessionPanelTrigger: React.FC = ({ + footerIconButtonClass, + iconSizeClass, }) => { const { t } = useI18n(); - const sessionCount = useDirectorySync(React.useCallback((state) => state.session.length, [])); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const currentSession = useSession(currentSessionId, currentDirectory || undefined); - const statusCounts = useLiveSessionStatusCounts(); - const unseenCounts = useNotificationStore((state) => state.index.session.unseenCount); - const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); - const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); - const contextUsage = useCurrentContextUsage(); - const { - currentProjectLabel, - currentProjectIcon, - currentProjectIconImageUrl, - currentProjectIconBackground, - currentProjectColor, - } = useCurrentProjectDisplay(); + const isMobile = useUIStore((state) => state.isMobile); + const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar); + const open = useUIStore((state) => state.mobileSessionPanelOpen); + const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen); - const totalUnread = React.useMemo(() => countUnreadSessions(unseenCounts), [unseenCounts]); - const currentSessionTitle = currentSession - ? getDisplaySessionTitle(currentSession) - : t('chat.mobileStatus.swipeHint'); + // Ensure the cross-project session list is loaded once, so the panel reflects + // every project, not just the active directory. + React.useEffect(() => { + if (isMobile && showMobileSessionStatusBar) { + void ensureGlobalSessionsLoaded(); + } + }, [isMobile, showMobileSessionStatusBar]); - const [editingSessionId, setEditingSessionId] = React.useState(null); - const [editingTitle, setEditingTitle] = React.useState(''); - - if (sessionCount === 0) { + if (!isMobile || !showMobileSessionStatusBar) { return null; } - const handleSessionDoubleClick = (sessionId: string, sessionTitle: string) => { - setEditingSessionId(sessionId); - setEditingTitle(sessionTitle); - }; - - const handleEditCancel = () => { - setEditingSessionId(null); - setEditingTitle(''); - }; - - const handleEditSave = () => { - if (!editingSessionId) return; - const trimmed = editingTitle.trim(); - const originalTitle = currentSession && currentSession.id === editingSessionId - ? getDisplaySessionTitle(currentSession) - : ''; - if (trimmed && trimmed !== originalTitle) { - void updateSessionTitle(editingSessionId, trimmed); - } - setEditingSessionId(null); - setEditingTitle(''); - }; - return ( - openNewSessionDraft()} - contextUsage={contextUsage} - editingSessionId={editingSessionId} - editingTitle={editingTitle} - onTitleDoubleClick={handleSessionDoubleClick} - onEditingTitleChange={setEditingTitle} - onEditSave={handleEditSave} - onEditCancel={handleEditCancel} - /> - ); -}; - -const MobileSessionStatusBarExpanded: React.FC void }> = ({ - onSessionSwitch, - onCollapse, -}) => { - const { t } = useI18n(); - const sessions = useSessions(); - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const sessionStatus = useAllSessionStatuses(); - const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); - const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); - const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); - const agents = useConfigStore((state) => state.agents); - const setActiveProject = useProjectsStore((state) => state.setActiveProject); - const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); - const removeProject = useProjectsStore((state) => state.removeProject); - const contextUsage = useCurrentContextUsage(); - const { - projects, - activeProjectId, - homeDirectory, - currentProjectLabel, - currentProjectIcon, - currentProjectIconImageUrl, - currentProjectIconBackground, - currentProjectColor, - } = useCurrentProjectDisplay(); - - const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus); - const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus); - const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId); - - const currentSession = sessions.find((s) => s.id === currentSessionId); - const currentSessionTitle = currentSession - ? getSessionTitle(currentSession) - : t('chat.mobileStatus.swipeHint'); - - // Calculate current session's child indicators - const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId); - const currentSessionChildIndicators = currentSessionWithStatus?._childIndicators ?? []; - - const [isExpanded, setIsExpanded] = React.useState(false); - const [editingSessionId, setEditingSessionId] = React.useState(null); - const [editingTitle, setEditingTitle] = React.useState(''); - - if (totalCount === 0) { - return null; - } - - const handleSessionClick = (sessionId: string) => { - if (editingSessionId) return; - setCurrentSession(sessionId); - onSessionSwitch?.(sessionId); - setIsExpanded(false); - }; - - const handleSessionDoubleClick = (sessionId: string, sessionTitle: string) => { - setEditingSessionId(sessionId); - setEditingTitle(sessionTitle); - }; - - const handleEditCancel = () => { - setEditingSessionId(null); - setEditingTitle(''); - }; - - const handleEditSave = () => { - if (!editingSessionId) return; - const trimmed = editingTitle.trim(); - const target = sessions.find((s) => s.id === editingSessionId); - const originalTitle = target ? getSessionTitle(target) : ''; - if (trimmed && trimmed !== originalTitle) { - void updateSessionTitle(editingSessionId, trimmed); - } - setEditingSessionId(null); - setEditingTitle(''); - }; - - const handleEditingTitleChange = (value: string) => { - setEditingTitle(value); - }; - - const handleCreateSession = () => { - openNewSessionDraft(); - }; - - const handleProjectSwitch = (projectId: string) => { - if (projectId === activeProjectId) { - return; - } - const draft = useSessionUIStore.getState().newSessionDraft; - if (draft?.open) { - if ( - draft.pendingWorktreeRequestId || - draft.bootstrapPendingDirectory || - draft.preserveDirectoryOverride - ) { - return; - } - const project = projects.find((p) => p.id === projectId); - if (project) { - setActiveProjectIdOnly(projectId); - useSessionUIStore.getState().setNewSessionDraftTarget({ - projectId, - directoryOverride: project.path, - }); - } - return; - } - setActiveProject(projectId); - }; - - const handleAddProject = () => { - sessionEvents.requestDirectoryDialog(); - }; - - return ( - { - onCollapse(); - setIsExpanded(false); + ); }; export const MobileSessionStatusBar: React.FC = ({ onSessionSwitch, }) => { + const { t } = useI18n(); + const { currentTheme } = useThemeSystem(); + const sessions = useAllProjectSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessionStatus = useAllSessionStatuses(); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const getContextUsage = useSessionUIStore((state) => state.getContextUsage); + const agents = useConfigStore((state) => state.agents); + const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const isMobile = useUIStore((state) => state.isMobile); const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar); - const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed); - const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed); + const open = useUIStore((state) => state.mobileSessionPanelOpen); + const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen); + + const projects = useProjectsStore((state) => state.projects); + const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + + const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus); + const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents); + const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId); + const resolveProjectRoots = useProjectRootsResolver(); + + // Project filter, persisted in the UI store so the choice survives closing and + // reopening the sheet. Defaults to "All" so sessions from every project are + // visible regardless of which session is currently selected. + const filterProjectId = useUIStore((state) => state.mobileSessionFilterProjectId); + const setFilterProjectId = useUIStore((state) => state.setMobileSessionFilterProjectId); + + // Refresh the cross-project session list when the panel opens (mirrors the + // dedicated MobileSessionsSheet). The active-directory sync only upserts the + // current project's sessions, so other projects need this global load. + React.useEffect(() => { + if (open) { + void refreshGlobalSessions(sessions); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const formatProjectLabel = React.useCallback((project: ProjectEntry): string => { + return project.label?.trim() + || formatDirectoryName(project.path, homeDirectory) + || project.path; + }, [homeDirectory]); + + // Filter sessions by the selected project (root + worktrees), using the + // store's canonical directory keying. + const filteredSessions = React.useMemo(() => { + if (!filterProjectId) return sortedSessions; + const project = projects.find((p) => p.id === filterProjectId); + if (!project) return sortedSessions; + const roots = resolveProjectRoots(project); + return sortedSessions.filter((session) => { + const dir = sessionDirectory(session); + return roots.some((root) => pathBelongsToRoot(dir, root)); + }); + }, [sortedSessions, filterProjectId, projects, resolveProjectRoots]); + + // Cap to the most recent N (already sorted running-first, then by updated). + const visibleSessions = React.useMemo( + () => filteredSessions.slice(0, MAX_RECENT_SESSIONS), + [filteredSessions], + ); + + // Token usage for the current session. + const currentModel = getCurrentModel(); + const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null + ? (currentModel.limit as Record) + : null; + const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0); + const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0); + const contextUsage = getContextUsage(contextLimit, outputLimit); + + const handleSessionClick = (session: SessionWithStatus) => { + setCurrentSession(session.id, sessionDirectory(session) || null); + onSessionSwitch?.(session.id); + setOpen(false); + }; + + const renderHeader = React.useCallback((closeButton: React.ReactNode) => ( +
+
+
+
+ +
+

+ {t('mobile.sessions.search.section.sessions')} +

+
+ + + + {closeButton} +
+
+ + {projects.length > 1 && ( +
+ setFilterProjectId(null)} + /> + {projects.map((project) => ( + setFilterProjectId(project.id)} + /> + ))} +
+ )} +
+ ), [t, totalRunning, totalUnread, contextUsage, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus]); if (!isMobile || !showMobileSessionStatusBar) { return null; } - if (isMobileSessionStatusBarCollapsed) { - return ( - setIsMobileSessionStatusBarCollapsed(false)} - /> - ); - } - return ( - setIsMobileSessionStatusBarCollapsed(true)} - /> + setOpen(false)} + title={t('mobile.sessions.search.section.sessions')} + renderHeader={renderHeader} + className="h-[72vh]" + contentMaxHeightClassName="max-h-full" + > +
+ {visibleSessions.length === 0 ? ( +
+ {t('chat.mobileStatus.noSessionsInProject')} +
+ ) : ( + visibleSessions.map((session) => ( + handleSessionClick(session)} + needsAttention={needsAttention} + /> + )) + )} +
+
); }; diff --git a/packages/ui/src/components/chat/PendingChangesBar.tsx b/packages/ui/src/components/chat/PendingChangesBar.tsx index d7a5ad89..23d81614 100644 --- a/packages/ui/src/components/chat/PendingChangesBar.tsx +++ b/packages/ui/src/components/chat/PendingChangesBar.tsx @@ -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); diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts new file mode 100644 index 00000000..7c45cd4f --- /dev/null +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index b2198ff0..41befabc 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -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 }, diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index bbe14da3..fc53e605 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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(() => { diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index 3a2cce18..d322cca5 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -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})`)); diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 8fdd616e..72a73685 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -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 => { + 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 ; if (status === 'ok') return ; if (status === 'auth') return ; + if (status === 'update-recommended') return ; + if (status === 'incompatible') return ; if (status === 'wrong-service') return ; if (status === 'unreachable') return ; return ; @@ -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([]); const [defaultHostId, setDefaultHostId] = React.useState(null); const [statusById, setStatusById] = React.useState>({}); + const [probingHostIds, setProbingHostIds] = React.useState>({}); 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(''); + const [localOrigin, setLocalOrigin] = React.useState(() => getLocalOrigin()); const [editingId, setEditingId] = React.useState(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 = {}; 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 = {}; + 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({
)} - {tauriAvailable && ( -
- {t('desktopHostSwitcher.ssh.needInstancesHint')} - -
- )} - {!tauriAvailable && (
@@ -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 })} > -
-
- - {displayLabel} - - {isSsh && ( - - SSH +
+
+
+ + {displayLabel} - )} - {isActive && ( - {t('desktopHostSwitcher.header.current')} - )} - - {statusIcon(statusKind)} - - {isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(status?.status ?? null))} - {!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number' + {isSsh && ( + + SSH + + )} + {isActive && ( + {t('desktopHostSwitcher.header.current')} + )} +
+ + {statusIcon(statusKind)} + + {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)) }) : ''} @@ -841,52 +894,6 @@ export function DesktopHostSwitcherDialog({
- {!isLocal && !isSsh && ( - - - - - - { - e.stopPropagation(); - beginEdit(host); - }} - disabled={isSaving} - > - - {t('desktopHostSwitcher.actions.edit')} - - { - e.stopPropagation(); - void deleteHost(host.id); - }} - className="text-destructive focus:text-destructive" - disabled={isSaving} - > - - {t('desktopHostSwitcher.actions.delete')} - - - - )} - - {isLocal && ( - )} - {embedded && !isAddFormOpen ? ( -
- -
- ) : ( -
-
-
{t('desktopHostSwitcher.add.title')}
-
- {embedded && ( - - )} - -
-
-
- setNewLabel(e.target.value)} - onKeyDown={stopDropdownTypeahead} - placeholder={t('desktopHostSwitcher.field.labelOptionalPlaceholder')} - disabled={!tauriAvailable || isSaving} - /> - setNewUrl(e.target.value)} - onKeyDown={stopDropdownTypeahead} - placeholder={t('desktopHostSwitcher.field.urlPlaceholder')} - disabled={!tauriAvailable || isSaving} - /> -
-
- )} +
+ +
{error && (
{error}
@@ -1102,7 +1057,7 @@ export function DesktopHostSwitcherDialog({ type="button" size="sm" variant="outline" - onClick={switchToLocal} + onClick={() => void switchToLocal()} > {t('desktopHostSwitcher.actions.switchToLocal')} @@ -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(null); + const [localOrigin, setLocalOrigin] = React.useState(() => 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 diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index d3f178b2..c6de7376 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -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): 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 = ({ 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({ status: 'idle' }); + const [urlAuthReadyKey, setUrlAuthReadyKey] = React.useState(''); const iframeRef = React.useRef(null); const nextConsoleEventIdRef = React.useRef(1); const [bridgeReady, setBridgeReady] = React.useState(false); @@ -480,6 +511,7 @@ const PreviewPane: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ rawUrl, onNavigate }) => { void (async () => { const probe = async (): Promise => { try { - return await fetch(proxySrc, { + return await runtimeFetch(proxySrc, { method: 'GET', credentials: 'include', cache: 'no-store', @@ -882,7 +942,7 @@ const PreviewPane: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ rawUrl, onNavigate }) => { } catch { // Cross-origin frames are expected for non-loopback/direct previews. } - }, [isLoopback, proxyState]); + }, [isLoopback, proxySrc, proxyState]); return (
@@ -1195,6 +1256,7 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire const [isInspecting, setIsInspecting] = React.useState(false); const [hoverTarget, setHoverTarget] = React.useState(null); const [proxyState, setProxyState] = React.useState({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 : ''); diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index df1b3501..7903aa89 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -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>; refreshCurrentInstanceLabel: () => Promise; @@ -346,6 +351,10 @@ type DesktopServicesMenuProps = { showDevShutdown: boolean; isDevShutdownInFlight: boolean; onDevShutdown: () => Promise; + 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({
{isDesktopApp && desktopServicesTab === 'instance' ? ( - {}} - onHostSwitched={() => setIsDesktopServicesOpen(false)} - /> +
+ {!currentInstanceIsLocal ? ( +
+
+
+
{t('header.services.remoteUpdate.title')}
+
+ {remoteUpdateInfo?.available + ? t('header.services.remoteUpdate.available', { version: remoteUpdateInfo.version || '' }) + : remoteUpdateChecking + ? t('header.services.remoteUpdate.checking') + : remoteUpdateError || t('header.services.remoteUpdate.upToDate')} +
+
+ {remoteUpdateInfo?.available ? ( + + ) : null} +
+
+ ) : null} + {}} + onHostSwitched={() => setIsDesktopServicesOpen(false)} + /> +
) : null} {desktopServicesTab === 'mcp' ? ( @@ -889,6 +930,11 @@ export const Header: React.FC = ({ 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(null); + const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false); + const [remoteUpdateError, setRemoteUpdateError] = React.useState(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 = ({ } 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ } 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 = ({ 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 = ({ isDesktopApp={isDesktopApp} currentInstanceLabel={currentInstanceLabel} compactCurrentInstanceLabel={compactCurrentInstanceLabel} + currentInstanceIsLocal={currentInstanceIsLocal} isDesktopServicesOpen={isDesktopServicesOpen} setIsDesktopServicesOpen={setIsDesktopServicesOpen} refreshCurrentInstanceLabel={refreshCurrentInstanceLabel} @@ -1953,6 +2098,10 @@ export const Header: React.FC = ({ showDevShutdown={showDevShutdown} isDevShutdownInFlight={isDevShutdownInFlight} onDevShutdown={handleDevShutdown} + remoteUpdateInfo={remoteUpdateInfo} + remoteUpdateChecking={remoteUpdateChecking} + remoteUpdateError={remoteUpdateError} + onOpenRemoteUpdate={openRemoteInstanceUpdate} /> = ({ ); return ( -
- {isMobile ? renderMobile() : renderDesktop()} -
+ <> +
+ {isMobile ? renderMobile() : renderDesktop()} +
+ {}} + onRestart={() => {}} + runtimeType="web" + /> + ); }; diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index d6f74cec..b23425a5 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -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 }))); diff --git a/packages/ui/src/components/layout/ProjectEditDialog.tsx b/packages/ui/src/components/layout/ProjectEditDialog.tsx index 06ee364e..4fb3aff0 100644 --- a/packages/ui/src/components/layout/ProjectEditDialog.tsx +++ b/packages/ui/src/components/layout/ProjectEditDialog.tsx @@ -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 = ({ 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 = ({ ); })}
- {effectiveHasImageIcon && iconPreviewUrl && ( + {effectiveHasImageIcon && showImagePreview && (
{t('projectEditDialog.field.preview')} @@ -360,13 +349,25 @@ export const ProjectEditDialog: React.FC = ({ className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]" style={iconBackground ? { backgroundColor: iconBackground } : undefined} > - setPreviewImageFailed(true)} - /> + {hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? ( + setPreviewImageFailed(true)} + /> + ) : ( + setPreviewImageFailed(true)} + /> + )}
diff --git a/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx b/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx index b0e98355..adf0e057 100644 --- a/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx +++ b/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx @@ -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')); diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 8a5ddefc..c56d6c54 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -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 = ({ 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 ? ( + + ) : ( + + ); return ( - {imageUrl ? ( + {project.iconImage ? ( - + - ) : projectIconName ? ( - - ) : ( - - )} + ) : fallbackIcon} {displayLabel} ); diff --git a/packages/ui/src/components/onboarding/ChooserScreen.tsx b/packages/ui/src/components/onboarding/ChooserScreen.tsx index ddf68cf8..88e19162 100644 --- a/packages/ui/src/components/onboarding/ChooserScreen.tsx +++ b/packages/ui/src/components/onboarding/ChooserScreen.tsx @@ -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 => { 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); } diff --git a/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx b/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx index b3ae0307..4e4941d3 100644 --- a/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx +++ b/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx @@ -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({
{/* Host info if available */} - {hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service') && ( + {hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service' || variant === 'remote-incompatible') && (
{t('onboarding.remoteConnection.field.serverAddress')}
{redactSensitiveUrl(hostUrl)}
diff --git a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx index f33ae588..b04c4246 100644 --- a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx +++ b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx @@ -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 => { 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); } diff --git a/packages/ui/src/components/onboarding/RecoveryScreen.tsx b/packages/ui/src/components/onboarding/RecoveryScreen.tsx index fc9b0c96..83fa4552 100644 --- a/packages/ui/src/components/onboarding/RecoveryScreen.tsx +++ b/packages/ui/src/components/onboarding/RecoveryScreen.tsx @@ -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]); diff --git a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx index 31a83637..a17a91e9 100644 --- a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx +++ b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx @@ -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(null); const [error, setError] = useState(''); - const normalizedUrl = normalizeHostUrl(url); + const resolvedUrl = resolveDesktopHostUrl(url); + const normalizedUrl = resolvedUrl?.persistedUrl ?? null; const handleUrlChange = useCallback((e: React.ChangeEvent) => { 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) => Promise } } }).__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({
)} + {probeResult && isUpdateRecommended && ( +
+ {probeMessageKey ? t(probeMessageKey as Parameters[0]) : null} +
+ )} + {/* Blocking errors */} {probeResult && isBlocking && (
{ 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) // --------------------------------------------------------------------------- diff --git a/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts b/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts index bdbaf369..95034a76 100644 --- a/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts +++ b/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts @@ -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', diff --git a/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts b/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts index b270f56c..476b7466 100644 --- a/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts +++ b/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts @@ -17,6 +17,10 @@ const EXPECTED_ROUTING: Record = { }; const saveBehaviorSetting = async (settings: Partial, 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', diff --git a/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx b/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx index d6565726..20a998f5 100644 --- a/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx +++ b/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx @@ -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.')); diff --git a/packages/ui/src/components/sections/mcp/McpPage.tsx b/packages/ui/src/components/sections/mcp/McpPage.tsx index 404c9fd6..f98f94a5 100644 --- a/packages/ui/src/components/sections/mcp/McpPage.tsx +++ b/packages/ui/src/components/sections/mcp/McpPage.tsx @@ -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 => { - 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 = ( diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index 76d86864..1d60f005 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -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 }), diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx index dc8e8a3a..948a2235 100644 --- a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx @@ -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', diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index 6aa19a03..dd7a2398 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -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; } - 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', diff --git a/packages/ui/src/components/sections/openchamber/GitSettings.tsx b/packages/ui/src/components/sections/openchamber/GitSettings.tsx index beeb9efe..8a9fb78c 100644 --- a/packages/ui/src/components/sections/openchamber/GitSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitSettings.tsx @@ -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' }, }); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 05401002..f1a64b0f 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -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 = ({ 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 (
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 4c3fd11e..f9df4f25 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -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[] = [ }, ]; +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 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 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(() => 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 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 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
+ {showMobileLayoutSetting && ( +
+ {t('settings.openchamber.visual.section.mobileLayout')} +
+ {MOBILE_LAYOUT_OPTIONS.map((option) => ( + + ))} +
+
+ )} +
{t('settings.openchamber.visual.field.lightTheme')} diff --git a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx index e78090d3..7feaa686 100644 --- a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx @@ -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' }, }); diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx index 853144c6..2669a5ad 100644 --- a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx @@ -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 : []); diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index 6beeaf48..8b1bf656 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -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({ diff --git a/packages/ui/src/components/sections/projects/ProjectsPage.tsx b/packages/ui/src/components/sections/projects/ProjectsPage.tsx index ec58255e..3ac2f9c1 100644 --- a/packages/ui/src/components/sections/projects/ProjectsPage.tsx +++ b/packages/ui/src/components/sections/projects/ProjectsPage.tsx @@ -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 = () => { ); })}
- {effectiveHasImageIcon && iconPreviewUrl && ( + {effectiveHasImageIcon && showImagePreview && (
{t('settings.projects.page.field.preview')} @@ -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} > - setPreviewImageFailed(true)} - /> + {hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? ( + setPreviewImageFailed(true)} + /> + ) : selectedProject ? ( + setPreviewImageFailed(true)} + /> + ) : null}
diff --git a/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx index 0c0d0b49..bb820307 100644 --- a/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx +++ b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx @@ -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>(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 - ? ( - - { - setBrokenIconIds((prev) => { - if (prev.has(imageFailureKey)) { - return prev; - } - const next = new Set(prev); - next.add(imageFailureKey); - return next; - }); - }} - /> - - ) - : iconName + const fallbackIcon = iconName ? ( ) : ( ); + const icon = project.iconImage + ? ( + + + + ) + : fallbackIcon; return ( { 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 = isRecord(result.data) ? result.data : {}; + const nestedData = payloadRecord.data; + const dataRecord: Record = 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')); diff --git a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx index 59957c7b..39dac0a2 100644 --- a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx @@ -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 = ({ 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' }, }); diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 6a6461d9..0b755f08 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -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([]); + const [directDefaultHostId, setDirectDefaultHostId] = React.useState('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(null); + const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false); + const [directImportDialogOpen, setDirectImportDialogOpen] = React.useState(false); + const [directEditingId, setDirectEditingId] = React.useState(null); + const [directEditLabel, setDirectEditLabel] = React.useState(''); + const [directEditUrl, setDirectEditUrl] = React.useState(''); + const [directEditToken, setDirectEditToken] = React.useState(''); + const [remoteClients, setRemoteClients] = React.useState([]); + const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false); + const [remoteClientLabel, setRemoteClientLabel] = React.useState(''); + const [createdRemoteClientToken, setCreatedRemoteClientToken] = React.useState(null); + const [remoteClientError, setRemoteClientError] = React.useState(null); + const [pairingUrl, setPairingUrl] = React.useState(null); + const [pairingQrDataUrl, setPairingQrDataUrl] = React.useState(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 ( -
-
-

{t('settings.remoteInstances.page.title')}

-

{t('settings.remoteInstances.page.description')}

+ {clientAuth ? ( +
+
+

{t('settings.remoteInstances.clientAuth.title')}

+

{t('settings.remoteInstances.clientAuth.description')}

+
+
+
+ setRemoteClientLabel(event.target.value)} placeholder={t('settings.remoteInstances.clientAuth.field.labelPlaceholder')} /> + + +
+ {pairingUrl ? ( +
+ {pairingQrDataUrl ? {t('settings.remoteInstances.clientAuth.qrAlt')} : null} +
+

{t('settings.remoteInstances.clientAuth.pairingUrl')}

+ {pairingUrl} + +
+
+ ) : null} + {createdRemoteClientToken ? ( +
+

{t('settings.remoteInstances.clientAuth.createdToken')}

+ {createdRemoteClientToken} +
+ ) : null} +
+ {revokedClientCount > 0 ? ( +
+ +
+ ) : null} + {remoteClientsLoading ? ( +

{t('settings.remoteInstances.clientAuth.state.loading')}

+ ) : remoteClients.length === 0 ? ( +

{t('settings.remoteInstances.clientAuth.state.empty')}

+ ) : remoteClients.map((client) => { + const isLocalDesktopClient = client.clientKind === 'desktop-local'; + return ( +
+
+
+

{client.label}

+ {isLocalDesktopClient ? ( + + {t('settings.remoteInstances.clientAuth.state.thisDevice')} + + ) : null} +
+

{client.revokedAt ? t('settings.remoteInstances.clientAuth.state.revoked') : client.lastUsedAt ? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt }) : t('settings.remoteInstances.clientAuth.neverUsed')}

+
+ +
+ ); + })} +
+ {remoteClientError ?

{remoteClientError}

: null} +
-
-

{t('settings.remoteInstances.page.empty.selectInstance')}

-
-
+ ) : null} -
+ {showInstanceManagement ?
+
+

{t('settings.remoteInstances.direct.title')}

+

{t('settings.remoteInstances.direct.description')}

+
+
+
+

{t('settings.remoteInstances.direct.note')}

+
+ + +
+
+ +
+ {directLoading ? ( +

{t('settings.remoteInstances.direct.state.loading')}

+ ) : directHosts.length === 0 ? ( +

{t('settings.remoteInstances.direct.state.empty')}

+ ) : directHosts.map((host) => ( +
+
+
+
+

{redactSensitiveUrl(host.label)}

+ {directDefaultHostId === host.id ? {t('desktopHostSwitcher.header.default')} : null} +
+

{redactSensitiveUrl(host.apiUrl || host.url)}

+
+
+ + + +
+
+
+ ))} +
+ + {directError ?

{directError}

: null} +
+
: null} + + {showInstanceManagement ? + + + {t('settings.remoteInstances.direct.actions.add')} + {t('settings.remoteInstances.direct.description')} + +
{ event.preventDefault(); void handleAddDirectHost(); }}> + setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} /> + setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus /> + setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +
+ + +
+
+
+
: null} + + {showInstanceManagement ? { if (!open) setDirectEditingId(null); }}> + + + {t('desktopHostSwitcher.actions.edit')} + {t('settings.remoteInstances.direct.description')} + +
{ event.preventDefault(); void saveDirectHostEdit(); }}> + setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} /> + setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus /> + setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +
+ + +
+
+
+
: null} + + {showInstanceManagement ? + + + {t('settings.remoteInstances.direct.import.action')} + {t('settings.remoteInstances.direct.import.description')} + +
{ event.preventDefault(); void importDirectConnectLink(); }}> + setDirectConnectLink(event.target.value)} placeholder={t('settings.remoteInstances.direct.import.placeholder')} disabled={directSaving} autoFocus /> +
+ + +
+
+
+
: null} + + {showInstanceManagement ?
+
+
+
+

{t('settings.remoteInstances.sidebar.title')}

+

{t('settings.remoteInstances.sidebar.total', { count: instances.length })}

+
+ +
+
+
+ {isLoading ? ( +

{t('settings.remoteInstances.page.import.loading')}

+ ) : instances.length === 0 ? ( +

{t('settings.remoteInstances.page.import.noneFound')}

+ ) : 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 ( +
+
+
+ +

{title}

+
+

+ {t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''} +

+
+
+ + + +
+
+ ); + })} +
+
: null} + + {showInstanceManagement ? + + + {t('settings.remoteInstances.sidebar.actions.addSshInstance')} + {t('settings.remoteInstances.page.section.instanceDescription')} + +
{ event.preventDefault(); void createSshInstanceFromDialog(); }}> + setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} /> + setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus /> +
+ + +
+
+
+
: null} + + {showInstanceManagement ?

{t('settings.remoteInstances.page.import.sectionTitle')}

@@ -694,15 +1229,15 @@ export const RemoteInstancesPage: React.FC = () => { ) : importCandidates.length === 0 ? (

{t('settings.remoteInstances.page.import.noneFound')}

) : ( -
+
{importCandidates.map((candidate) => ( -
+
-
+
{candidate.host} {candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
-
{candidate.source} config
+
{candidate.sshCommand}
))}
)} -
+
: null} { const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id; return ( - + { if (!open) setSelectedId(null); }}> +

{instanceTitle}

@@ -1466,46 +2002,7 @@ export const RemoteInstancesPage: React.FC = () => {
-
-
-

{t('settings.remoteInstances.page.import.sectionTitle')}

-
-
- {isImportsLoading ? ( -

{t('settings.remoteInstances.page.import.loading')}

- ) : importCandidates.length === 0 ? ( -

{t('settings.remoteInstances.page.import.noneAvailable')}

- ) : ( -
- {importCandidates.slice(0, 8).map((candidate, index) => ( -
0 ? 'border-t border-[var(--surface-subtle)]' : ''}`} - > -
-
- {candidate.host} - {candidate.pattern ? ' (pattern)' : ''} -
-
{candidate.sshCommand}
-
- -
- ))} -
- )} -
-
- -
+
-
+ +
); }; diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx index 5abf1034..296d7bce 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx @@ -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 = ({ 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 = ({ }, [connect, t, upsertInstance]); return ( - @@ -151,6 +156,16 @@ export const RemoteInstancesSidebar: React.FC = ({
} > + { + setSelectedId(DIRECT_INSTANCES_ID); + onItemSelect?.(); + }} + icon={} + /> {instances.map((instance) => { const status = statusesById[instance.id]; const selected = instance.id === selectedId; diff --git a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx index cd6958de..bcc00670 100644 --- a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx @@ -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 => { 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' }, }); diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx index df1c81d8..c71d8432 100644 --- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx +++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx @@ -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 => { 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' }, }); diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 607f9339..052a9a6f 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -16,6 +16,7 @@ import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui'; import { IdentityDropdown } from '@/components/views/git/GitHeader'; +import { runtimeFetch } from '@/lib/runtime-fetch'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; @@ -120,7 +121,7 @@ const focusPathInput = (input: HTMLInputElement | null): void => { const resolveFreshFilesystemHome = async (): Promise => { try { - const response = await fetch('/api/fs/home', { + const response = await runtimeFetch('/api/fs/home', { method: 'GET', headers: { Accept: 'application/json' }, }); diff --git a/packages/ui/src/components/session/DirectoryTree.tsx b/packages/ui/src/components/session/DirectoryTree.tsx index 59cc8d06..eb1c5100 100644 --- a/packages/ui/src/components/session/DirectoryTree.tsx +++ b/packages/ui/src/components/session/DirectoryTree.tsx @@ -17,6 +17,7 @@ import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; interface DirectoryItem { name: string; @@ -281,7 +282,7 @@ export const DirectoryTree: React.FC = ({ try { let pinned: string[] = []; - const response = await fetch('/api/config/settings', { + const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' }, }); diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx index e60e76fa..3b3eb3f8 100644 --- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -366,7 +366,7 @@ export function GitHubIssuePickerDialog({ const sessionTitle = `#${issue.number} ${issue.title}`.trim(); - const sessionId = await (async () => { + const { sessionId, sessionDirectory } = await (async () => { if (createInWorktree) { const preferred = `issue-${issue.number}-${generateBranchSlug()}`; const created = await createWorktreeSessionForNewBranch( @@ -376,14 +376,14 @@ export function GitHubIssuePickerDialog({ if (!created?.id) { throw new Error('Failed to create worktree session'); } - return created.id; + return { sessionId: created.id, sessionDirectory: created.path }; } const session = await sessionActions.createSession(sessionTitle, projectDirectory, null); if (!session?.id) { throw new Error('Failed to create session'); } - return session.id; + return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory }; })(); // Ensure worktree-based sessions also get the issue title. @@ -468,6 +468,7 @@ export function GitHubIssuePickerDialog({ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], + directory: sessionDirectory, }).catch((e) => { const message = e instanceof Error ? e.message : String(e); toast.error(t('session.githubIssuePicker.toast.sendContextFailed'), { diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index f7f68b45..821da471 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -510,6 +510,7 @@ export function NewWorktreeDialog({ const sendLinkedContextMessage = React.useCallback(async (args: { sessionId: string; + directory: string; issue: GitHubIssue | null; pr: GitHubPullRequestSummary | null; includeDiff: boolean; @@ -576,6 +577,7 @@ export function NewWorktreeDialog({ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], + directory: args.directory, }); toast.success(t('session.newWorktree.toast.sessionFromIssue')); @@ -612,6 +614,7 @@ export function NewWorktreeDialog({ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], + directory: args.directory, }); toast.success(t('session.newWorktree.toast.sessionFromPr')); @@ -935,6 +938,7 @@ export function NewWorktreeDialog({ onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId }); void sendLinkedContextMessage({ sessionId: createdSessionId, + directory: metadata.path, issue: linkedIssue, pr: linkedPrState, includeDiff: includePrDiff, diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx index 9d8c0d4f..f5737d5d 100644 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx @@ -44,6 +44,7 @@ import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator' import { cn } from '@/lib/utils'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog'; const TODO_PANEL_MIN_ITEMS = 5; @@ -514,7 +515,7 @@ export const ProjectNotesTodoPanel: React.FC = ({ return; } sessionId = created.id; - directoryHint = null; + directoryHint = created.path; } else { const session = await createSession(undefined, projectRef.path, null); if (!session?.id) { @@ -619,7 +620,7 @@ export const ProjectNotesTodoPanel: React.FC = ({ path: result.path, allowOutsideWorkspace: 'true', }); - const response = await fetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' }); + const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' }); if (!response.ok) { toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed')); return; diff --git a/packages/ui/src/components/session/ScheduledTasksDialog.tsx b/packages/ui/src/components/session/ScheduledTasksDialog.tsx index 7924dd12..e1d72bdc 100644 --- a/packages/ui/src/components/session/ScheduledTasksDialog.tsx +++ b/packages/ui/src/components/session/ScheduledTasksDialog.tsx @@ -18,7 +18,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; -import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; +import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { cn, formatDirectoryName } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; @@ -195,30 +195,32 @@ export function ScheduledTasksDialog() { const renderProjectLabel = React.useCallback((project: ProjectEntry) => { const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory || undefined); - 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 ? ( + + ) : ( + + ); return ( - {imageUrl ? ( + {project.iconImage ? ( - + - ) : projectIconName ? ( - - ) : ( - - )} + ) : fallbackIcon} {displayLabel} ); diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index deff5e75..e9847d3b 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -21,7 +21,7 @@ import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getEx import type { ChildSessionExport } from '@/lib/exportSession'; import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; -import { useViewportStore } from '@/sync/viewport-store'; +import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { DraggableSessionRow } from './sessionFolderDnd'; import type { SessionNode, SessionSummaryMeta } from './types'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils'; @@ -29,6 +29,8 @@ import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useSessionUnseenCount } from '@/sync/notification-store'; import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'; import { useI18n } from '@/lib/i18n'; +import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth'; +import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { parseMultiRunSessionTitle } from '@/lib/multirun/title'; import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog'; import { FusionIcon } from '@/components/icons/FusionIcon'; @@ -326,7 +328,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`; const isZombie = useViewportStore( - React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]), + React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]), ); const sessionStatus = useGlobalSessionStatus(session.id); const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined); @@ -447,6 +449,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { void invokeDesktop('desktop_open_session_mini_chat_window', { sessionId: session.id, directory: sessionDirectory, + apiBaseUrl: getRuntimeApiBaseUrl(), + clientToken: getRuntimeBearerTokenSync(), }).catch((error) => { console.warn('[session-sidebar] failed to open mini chat window', error); }); diff --git a/packages/ui/src/components/session/sidebar/sortableItems.tsx b/packages/ui/src/components/session/sidebar/sortableItems.tsx index 0a970c34..7ea4b9be 100644 --- a/packages/ui/src/components/session/sidebar/sortableItems.tsx +++ b/packages/ui/src/components/session/sidebar/sortableItems.tsx @@ -10,7 +10,7 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; -import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; +import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useI18n } from '@/lib/i18n'; @@ -86,23 +86,12 @@ export const SortableProjectItem: React.FC = ({ isDragging, } = useSortable({ id }); - const [imageFailed, setImageFailed] = React.useState(false); const suppressNextToggleRef = React.useRef(false); const menuInstanceKey = `project:${id}`; const isMenuOpen = openSidebarMenuKey === menuInstanceKey; - React.useEffect(() => { - setImageFailed(false); - }, [id, projectIconImage?.updatedAt]); - const projectIconName = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null; const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null; - const imageUrl = !imageFailed - ? getProjectIconImageUrl({ id, iconImage: projectIconImage }, { - themeVariant: currentTheme.metadata.variant, - iconColor: currentTheme.colors.surface.foreground, - }) - : null; const handleMenuOpenChange = React.useCallback((open: boolean) => { setOpenSidebarMenuKey(open ? menuInstanceKey : null); @@ -179,7 +168,7 @@ export const SortableProjectItem: React.FC = ({ )}> {isCollapsed ? : } - {imageUrl ? ( + {projectIconImage ? ( = ({ )} style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined} > - setImageFailed(true)} + fallback={projectIconName ? ( + + ) : ( + + )} /> ) : projectIconName ? ( diff --git a/packages/ui/src/components/ui/AboutDialog.tsx b/packages/ui/src/components/ui/AboutDialog.tsx index a58cda8c..852a406b 100644 --- a/packages/ui/src/components/ui/AboutDialog.tsx +++ b/packages/ui/src/components/ui/AboutDialog.tsx @@ -10,6 +10,7 @@ import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { getDesktopAppVersion } from '@/lib/desktopNative'; +import { runtimeFetch } from '@/lib/runtime-fetch'; interface AboutDialogProps { open: boolean; @@ -64,7 +65,7 @@ export const AboutDialog: React.FC = ({ const fetchVersion = async () => { try { - const response = await fetch('/api/system/info'); + const response = await runtimeFetch('/api/system/info'); if (response.ok) { const data = await response.json(); if (typeof data.openchamberVersion === 'string' && data.openchamberVersion.trim()) { @@ -88,7 +89,7 @@ export const AboutDialog: React.FC = ({ let cancelled = false; const fetchOpenCodeVersion = async () => { try { - const response = await fetch('/api/opencode/upgrade-status', { + const response = await runtimeFetch('/api/opencode/upgrade-status', { headers: { Accept: 'application/json' }, }); if (!response.ok) return; diff --git a/packages/ui/src/components/ui/UpdateDialog.tsx b/packages/ui/src/components/ui/UpdateDialog.tsx index 243e9d22..8f87b8e6 100644 --- a/packages/ui/src/components/ui/UpdateDialog.tsx +++ b/packages/ui/src/components/ui/UpdateDialog.tsx @@ -12,6 +12,7 @@ import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; import { copyTextToClipboard } from '@/lib/clipboard'; import { openExternalUrl } from '@/lib/url'; import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error'; @@ -120,7 +121,7 @@ const WEB_UPDATE_MAX_WAIT_MS = 10 * 60 * 1000; async function installWebUpdate(): Promise { try { - const response = await fetch('/api/openchamber/update-install', { + const response = await runtimeFetch('/api/openchamber/update-install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); @@ -142,7 +143,7 @@ async function installWebUpdate(): Promise { async function isServerReachable(): Promise { try { - const response = await fetch('/health', { + const response = await runtimeFetch('/health', { method: 'GET', headers: { Accept: 'application/json' }, }); @@ -159,7 +160,7 @@ async function waitForUpdateApplied( ): Promise { for (let i = 0; i < maxAttempts; i++) { try { - const response = await fetch('/api/openchamber/update-check', { + const response = await runtimeFetch('/api/openchamber/update-check', { method: 'GET', headers: { Accept: 'application/json' }, }); diff --git a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx index 9c7fe1a3..7653c229 100644 --- a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx +++ b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx @@ -4,6 +4,7 @@ import { toast } from '@/components/ui/toast'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; import { getSafeStorage } from '@/stores/utils/safeStorage'; import { resolveOpenCodeUpdateVersion, @@ -51,7 +52,7 @@ export const OpenCodeUpdateToast: React.FC = () => { }); try { - const response = await fetch('/api/opencode/upgrade', { + const response = await runtimeFetch('/api/opencode/upgrade', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -134,7 +135,7 @@ export const OpenCodeUpdateToast: React.FC = () => { const checkForUpdate = async (attempt: number) => { try { - const response = await fetch('/api/opencode/upgrade-status', { headers: { Accept: 'application/json' } }); + const response = await runtimeFetch('/api/opencode/upgrade-status', { headers: { Accept: 'application/json' } }); if (!response.ok) throw new Error(response.statusText || 'OpenCode upgrade status check failed'); const status = await response.json().catch(() => null) as OpenCodeUpgradeStatusLike | null; const version = resolveOpenCodeUpgradeStatusVersion(status); diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index e8fe9110..d951d3d3 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { runtimeFetch } from '@/lib/runtime-fetch'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; @@ -35,6 +36,9 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDeviceInfo } from '@/lib/device'; import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers'; +import { getRuntimeUrlResolver } from '@/lib/runtime-url'; +import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; +import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; import type { Extension } from '@codemirror/state'; @@ -784,6 +788,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [fileLoading, setFileLoading] = React.useState(false); const [fileError, setFileError] = React.useState(null); const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); + const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState(''); const [loadedFilePath, setLoadedFilePath] = React.useState(null); @@ -1402,7 +1407,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { if (options?.optional) { params.set('optional', 'true'); } - const response = await fetch(`/api/fs/read?${params.toString()}`, { + const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { // Avoid conditional requests (304 + empty body). cache: options?.optional ? 'no-store' : 'default', }); @@ -2573,6 +2578,31 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { [lightTheme.metadata.id, darkTheme.metadata.id], ); + const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg + ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}` + : ''; + + React.useEffect(() => { + if (!imageAssetAuthKey) { + setImageAssetAuthReadyKey(''); + return; + } + + let cancelled = false; + setImageAssetAuthReadyKey(''); + void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl()) + .then((token) => { + if (!cancelled && token) setImageAssetAuthReadyKey(imageAssetAuthKey); + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [imageAssetAuthKey]); + + const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey); + const imageSrc = selectedFile?.path && isSelectedImage ? (runtime.isDesktop ? (isSelectedSvg @@ -2580,10 +2610,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { : desktopImageSrc) : (isSelectedSvg ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` - : `/api/fs/raw?${new URLSearchParams({ + : imageAssetAuthReadyKey === imageAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { path: selectedFile.path, - ...(selectedFileReadOptions.allowOutsideWorkspace ? { allowOutsideWorkspace: 'true' } : {}), - }).toString()}`)) + allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, + }) : '')) : ''; React.useEffect(() => { @@ -3205,7 +3235,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { {!selectedFile ? (
{t('filesView.editor.pickFileFromTree')}
- ) : fileLoading ? ( + ) : (fileLoading || isImageAssetAuthLoading) ? ( suppressFileLoadingIndicator ?
: ( @@ -3530,7 +3560,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { {renderFloatingFileControls({ exitFullscreenOnly: true })}
- {fileLoading ? ( + {(fileLoading || isImageAssetAuthLoading) ? ( suppressFileLoadingIndicator ?
: ( diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index c0de211a..06bacccc 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -41,9 +41,13 @@ interface PierreDiffViewerProps { layout?: 'fill' | 'inline'; } -// CSS injected into Pierre's Shadow DOM for WebKit scroll optimization -// Note: avoid will-change and contain:paint as they break resize behavior -const WEBKIT_SCROLL_FIX_CSS = ` +/** + * Base CSS injected into Pierre's Shadow DOM. Pins font-family/size to the + * app tokens (so Files view and Diff view render at the same scale on mobile) + * and enables touch-friendly line interactions. Re-exported so plain + * consumers (e.g. `MobileFilesSurface`) can inject the same. + */ +export const PIERRE_RUNTIME_BASE_CSS = ` :host { font-family: var(--font-mono); font-size: var(--text-code); @@ -65,6 +69,13 @@ const WEBKIT_SCROLL_FIX_CSS = ` pre[data-interactive-line-numbers] [data-line-number] { touch-action: manipulation; } +`; + +// CSS injected into Pierre's Shadow DOM for WebKit scroll optimization + +// diff-specific separator height. Note: avoid will-change and contain:paint +// as they break resize behavior. +const WEBKIT_SCROLL_FIX_CSS = ` + ${PIERRE_RUNTIME_BASE_CSS} [data-diff-header], [data-diff] { diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 0b3071e8..c621c1c5 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { runtimeFetch } from '@/lib/runtime-fetch'; import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { PreviewToggleButton } from './PreviewToggleButton'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; @@ -371,7 +372,7 @@ export const PlanView: React.FC = ({ targetPath = null }) => { return result?.content ?? ''; } - const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, { + const response = await runtimeFetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, { // Avoid conditional requests (304 + empty body). cache: 'no-store', }); @@ -475,7 +476,7 @@ export const PlanView: React.FC = ({ targetPath = null }) => { throw new Error(t('planView.error.writeFailed')); } } else { - const response = await fetch('/api/fs/write', { + const response = await runtimeFetch('/api/fs/write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: resolvedPath, content }), @@ -544,7 +545,7 @@ export const PlanView: React.FC = ({ targetPath = null }) => { return; } sessionId = created.id; - directoryHint = null; + directoryHint = created.path; } else { const sessionResult = await createSession(undefined, currentProjectRef.path, null); if (!sessionResult?.id) { diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index b963e843..b11bafa7 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -23,7 +23,6 @@ import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar'; import { SkillsPage } from '@/components/sections/skills/SkillsPage'; import { ProjectsSidebar } from '@/components/sections/projects/ProjectsSidebar'; import { ProjectsPage } from '@/components/sections/projects/ProjectsPage'; -import { RemoteInstancesSidebar } from '@/components/sections/remote-instances/RemoteInstancesSidebar'; import { RemoteInstancesPage } from '@/components/sections/remote-instances/RemoteInstancesPage'; import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar'; import { ProvidersPage } from '@/components/sections/providers/ProvidersPage'; @@ -73,6 +72,8 @@ interface SettingsViewProps { forceMobile?: boolean; /** Rendered inside a window/dialog (skip traffic light padding) */ isWindowed?: boolean; + /** Restrict top-level settings navigation to a specific product surface. */ + visiblePageSlugs?: SettingsPageSlug[]; } const pageOrder: SettingsPageSlug[] = [ @@ -277,7 +278,7 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({ ); }; -export const SettingsView: React.FC = ({ onClose, forceMobile, isWindowed }) => { +export const SettingsView: React.FC = ({ onClose, forceMobile, isWindowed, visiblePageSlugs }) => { const { t } = useI18n(); const deviceInfo = useDeviceInfo(); const isMobile = forceMobile ?? deviceInfo.isMobile; @@ -306,12 +307,14 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const runtimeCtx = React.useMemo(() => buildRuntimeContext(isDesktopApp), [isDesktopApp]); const visiblePages = React.useMemo(() => { + const allowedPages = visiblePageSlugs ? new Set(visiblePageSlugs) : null; return SETTINGS_PAGE_METADATA .filter((page) => page.slug !== 'home') + .filter((page) => !allowedPages || allowedPages.has(page.slug)) .filter((page) => isPageAvailable(page, runtimeCtx)) .filter((page) => !(runtimeCtx.isVSCode && page.slug === 'projects')) .filter((page) => !(isMobile && page.slug === 'shortcuts')); - }, [runtimeCtx, isMobile]); + }, [runtimeCtx, isMobile, visiblePageSlugs]); const sortedFilteredPages = React.useMemo(() => { const rank = new Map(pageOrder.map((s, i) => [s, i])); @@ -510,8 +513,6 @@ export const SettingsView: React.FC = ({ onClose, forceMobile switch (slug) { case 'projects': return ; - case 'remote-instances': - return ; case 'agents': return ; case 'commands': @@ -840,21 +841,23 @@ export const SettingsView: React.FC = ({ onClose, forceMobile {isMobile ? (
- + {(showBackButton || onClose) ? ( + + ) : null} -
+
{mobileStage === 'nav' ? t('settings.view.home.title') : (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))} diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx index 0e734283..61ad0d09 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx @@ -15,6 +15,7 @@ import { Icon } from "@/components/icon/Icon"; import { isIMECompositionEvent } from '@/lib/ime'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { ProjectRef } from '@/lib/openchamberConfig'; import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun'; import { useI18n } from '@/lib/i18n'; @@ -68,6 +69,7 @@ export const AgentManagerEmptyState: React.FC = ({ const commandRef = React.useRef(null); const { currentTheme } = useThemeSystem(); + const { runtime } = useRuntimeAPIs(); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const { isGitRepository, isLoading: isLoadingBranches } = useBranchOptions(currentDirectory); @@ -79,13 +81,7 @@ export const AgentManagerEmptyState: React.FC = ({ return typeof folder === 'string' && folder.trim().length > 0 ? folder.trim() : null; }, []); - const isVSCodeRuntime = React.useMemo(() => { - if (typeof window === 'undefined') { - return false; - } - const apis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__; - return Boolean(apis?.runtime?.isVSCode); - }, []); + const isVSCodeRuntime = runtime.isVSCode; // Get project directory for setup commands const activeProjectId = useProjectsStore((state) => state.activeProjectId); diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx index 1720c8e4..9cd4bb94 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx @@ -8,6 +8,7 @@ import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { CreateMultiRunParams } from '@/types/multirun'; interface AgentManagerViewProps { @@ -15,12 +16,8 @@ interface AgentManagerViewProps { } export const AgentManagerView: React.FC = ({ className }) => { - const isVSCodeRuntime = Boolean( - (typeof window !== 'undefined' - ? (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }) - .__OPENCHAMBER_RUNTIME_APIS__?.runtime?.isVSCode - : false) - ); + const { runtime } = useRuntimeAPIs(); + const isVSCodeRuntime = runtime.isVSCode; const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>( () => (typeof window !== 'undefined' diff --git a/packages/ui/src/components/views/git/ChangesPanel.tsx b/packages/ui/src/components/views/git/ChangesPanel.tsx index d09842f2..2cd2d0f9 100644 --- a/packages/ui/src/components/views/git/ChangesPanel.tsx +++ b/packages/ui/src/components/views/git/ChangesPanel.tsx @@ -49,6 +49,7 @@ interface ChangesPanelProps { diffStats: Record | undefined; revertingPaths: Set; isRevertingAll?: boolean; + headerBackgroundClassName?: string; onVisiblePathsChange?: (paths: string[]) => void; /** Reverts every changed path across all groups; rendered once for the panel. */ onRevertAll?: (paths: string[]) => Promise | void; @@ -73,6 +74,7 @@ export const ChangesPanel: React.FC = ({ diffStats, revertingPaths, isRevertingAll = false, + headerBackgroundClassName = 'bg-sidebar', onVisiblePathsChange, onRevertAll, }) => { @@ -290,7 +292,8 @@ export const ChangesPanel: React.FC = ({ return (
= ({
); }, - [collapsedGroups, toggleGroupCollapsed] + [collapsedGroups, headerBackgroundClassName, toggleGroupCollapsed] ); const renderDirectory = React.useCallback( diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index 5da21dad..4bfa4f60 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -20,6 +20,7 @@ import { } from '@/lib/theme/themes'; import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context'; import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter'; +import { runtimeFetch } from '@/lib/runtime-fetch'; type ThemePreferences = { themeMode: ThemeMode; @@ -283,7 +284,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro setCustomThemesLoading(true); try { - const res = await fetch('/api/config/themes', { + const res = await runtimeFetch('/api/config/themes', { method: 'GET', credentials: isLocalDesktopOrigin ? 'omit' : 'include', headers: { diff --git a/packages/ui/src/contexts/runtimeAPIRegistry.ts b/packages/ui/src/contexts/runtimeAPIRegistry.ts index 64af199e..9f485e42 100644 --- a/packages/ui/src/contexts/runtimeAPIRegistry.ts +++ b/packages/ui/src/contexts/runtimeAPIRegistry.ts @@ -6,4 +6,15 @@ export const registerRuntimeAPIs = (apis: RuntimeAPIs | null): void => { registeredRuntimeAPIs = apis; }; -export const getRegisteredRuntimeAPIs = (): RuntimeAPIs | null => registeredRuntimeAPIs; +export const getRegisteredRuntimeAPIs = (): RuntimeAPIs | null => { + if (registeredRuntimeAPIs) { + return registeredRuntimeAPIs; + } + + if (typeof window === 'undefined') { + return null; + } + + return (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }) + .__OPENCHAMBER_RUNTIME_APIS__ ?? null; +}; diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index ad87006e..59c281bd 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -2,7 +2,7 @@ import React from 'react'; import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy'; -import { useViewportStore, type SessionMemoryState } from '@/sync/viewport-store'; +import { getViewportSessionMemory, useViewportStore, type SessionMemoryState } from '@/sync/viewport-store'; export type AutoFollowState = 'following' | 'released'; @@ -358,7 +358,7 @@ export const useChatAutoFollow = ({ } pendingInitialRestoreRef.current = null; - const saved = useViewportStore.getState().sessionMemoryState.get(sessionId)?.scrollPosition; + const saved = getViewportSessionMemory(sessionId)?.scrollPosition; if (!saved || isAtBottomSnapshot(saved, isMobile)) { setStateValue('following'); @@ -378,7 +378,7 @@ export const useChatAutoFollow = ({ setStateValue('released'); writeScrollTopInstant(targetTop); - const memState = useViewportStore.getState().sessionMemoryState.get(sessionId); + const memState = getViewportSessionMemory(sessionId); updateViewportAnchor(sessionId, memState?.viewportAnchor ?? 0, { scrollTop: container.scrollTop, scrollHeight: container.scrollHeight, diff --git a/packages/ui/src/hooks/useSayTTS.ts b/packages/ui/src/hooks/useSayTTS.ts index 21ba4de7..ae2d7ca6 100644 --- a/packages/ui/src/hooks/useSayTTS.ts +++ b/packages/ui/src/hooks/useSayTTS.ts @@ -18,6 +18,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; +import { runtimeFetch } from '@/lib/runtime-fetch'; interface SayTTSStatusCache { available: boolean; @@ -45,7 +46,7 @@ async function getSayTTSStatus(): Promise { sayTTSStatusRequest = (async () => { try { - const response = await fetch('/api/tts/say/status'); + const response = await runtimeFetch('/api/tts/say/status'); if (!response.ok) { const unavailableStatus: SayTTSStatusCache = { available: false, @@ -219,7 +220,7 @@ export function useSayTTS(options: UseSayTTSOptions = {}): UseSayTTSReturn { abortControllerRef.current = new AbortController(); // Fetch audio from server - const response = await fetch('/api/tts/say/speak', { + const response = await runtimeFetch('/api/tts/say/speak', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/ui/src/hooks/useServerTTS.ts b/packages/ui/src/hooks/useServerTTS.ts index e95c83d8..ddeaa682 100644 --- a/packages/ui/src/hooks/useServerTTS.ts +++ b/packages/ui/src/hooks/useServerTTS.ts @@ -18,6 +18,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useConfigStore } from '@/stores/useConfigStore'; +import { runtimeFetch } from '@/lib/runtime-fetch'; interface ServerTTSStatusCache { available: boolean; @@ -45,7 +46,7 @@ async function getServerTTSStatus(): Promise { serverTTSStatusRequest = (async () => { try { - const response = await fetch('/api/tts/status'); + const response = await runtimeFetch('/api/tts/status'); if (!response.ok) { serverTTSStatusCache = { available: false, checkedAt: Date.now() }; return false; @@ -262,7 +263,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet console.log('[useServerTTS] Speaking with voice:', voice, 'options:', options); // Fetch audio from server - const response = await fetch('/api/tts/speak', { + const response = await runtimeFetch('/api/tts/speak', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/ui/src/hooks/useSessionAutoCleanup.ts b/packages/ui/src/hooks/useSessionAutoCleanup.ts index ba03e440..8b5b486e 100644 --- a/packages/ui/src/hooks/useSessionAutoCleanup.ts +++ b/packages/ui/src/hooks/useSessionAutoCleanup.ts @@ -152,13 +152,11 @@ export const useSessionAutoCleanup = (enabledOrOptions?: boolean | CleanupOption continue; } - const scopedSdk = opencodeClient.getScopedSdkClient(directory); - try { if (sessionRetentionAction === 'archive') { - await scopedSdk.session.update({ sessionID: id, directory, time: { archived: Date.now() } }); + await opencodeClient.updateSession(id, { time: { archived: Date.now() } }, directory); } else { - await scopedSdk.session.delete({ sessionID: id, directory }); + await opencodeClient.deleteSession(id, directory); } completedIds.push(id); } catch { diff --git a/packages/ui/src/hooks/useWebNotificationStream.ts b/packages/ui/src/hooks/useWebNotificationStream.ts index b2f541fc..18954dae 100644 --- a/packages/ui/src/hooks/useWebNotificationStream.ts +++ b/packages/ui/src/hooks/useWebNotificationStream.ts @@ -1,6 +1,7 @@ import React from 'react'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { isDesktopShell, isWebRuntime } from '@/lib/desktop'; +import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { useUIStore } from '@/stores/useUIStore'; import type { NotificationPayload } from '@/lib/api/types'; @@ -33,7 +34,7 @@ export const useWebNotificationStream = (options?: { enabled?: boolean }) => { return; } - const source = new EventSource(NOTIFICATION_STREAM_PATH); + const source = new EventSource(getRuntimeUrlResolver().sse(NOTIFICATION_STREAM_PATH)); source.onmessage = (event) => { let data: unknown; try { diff --git a/packages/ui/src/hooks/useWindowTitle.ts b/packages/ui/src/hooks/useWindowTitle.ts index e584442f..e05f0d38 100644 --- a/packages/ui/src/hooks/useWindowTitle.ts +++ b/packages/ui/src/hooks/useWindowTitle.ts @@ -1,8 +1,9 @@ import React from 'react'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { isDesktopShell, isTauriShell } from '@/lib/desktop'; -import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts'; +import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop'; +import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts'; import { setDesktopWindowTitle } from '@/lib/desktopNative'; +import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; const APP_TITLE = 'OpenChamber'; @@ -59,10 +60,17 @@ export const useWindowTitle = () => { const refreshInstanceLabel = async () => { try { - const currentHref = window.location.href; - const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin; + if (isDesktopLocalOriginActive()) { + if (!cancelled) { + setInstanceLabel(null); + } + return; + } - if (locationMatchesHost(currentHref, localOrigin)) { + const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin; + const runtimeApiBaseUrl = getRuntimeApiBaseUrl(); + + if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) { if (!cancelled) { setInstanceLabel(null); } @@ -70,7 +78,7 @@ export const useWindowTitle = () => { } const cfg = await desktopHostsGet(); - const match = cfg.hosts.find((host) => locationMatchesHost(currentHref, host.url)); + const match = cfg.hosts.find((host) => runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false); const nextLabel = match?.label?.trim() ? redactSensitiveUrl(match.label.trim()) : 'Instance'; if (!cancelled) { setInstanceLabel(nextLabel); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index edff5ee9..f108891f 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -716,6 +716,10 @@ export interface NotificationPayload { body?: string; tag?: string; + kind?: string; + sessionId?: string; + directory?: string; + requireHidden?: boolean; } export interface NotificationsAPI { @@ -746,6 +750,9 @@ export interface VSCodeAPI { executeCommand(command: string, ...args: unknown[]): Promise; openAgentManager(): Promise; openExternalUrl(url: string): Promise; + pickFiles?(): Promise; + saveImage?(payload: unknown): Promise; + saveMarkdown?(payload: unknown): Promise; } export interface PushSubscribePayload { @@ -1075,6 +1082,37 @@ export interface GitHubAPI { repoBranches(owner: string, repo: string): Promise; } +export interface RemoteClientRecord { + id: string; + label: string; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + expiresAt?: string | null; + clientKind?: string | null; +} + +export interface RemoteClientCreateResult { + client: RemoteClientRecord; + token: string; +} + +export interface RemoteClientRevokeResult { + revoked: boolean; + client?: RemoteClientRecord; +} + +export interface RemoteClientPurgeRevokedResult { + purged: number; +} + +export interface ClientAuthAPI { + listClients(): Promise; + createClient(input?: { label?: string }): Promise; + purgeRevokedClients(): Promise; + revokeClient(id: string): Promise; +} + export interface RuntimeAPIs { runtime: RuntimeDescriptor; terminal: TerminalAPI; @@ -1086,6 +1124,7 @@ export interface RuntimeAPIs { github?: GitHubAPI; push?: PushAPI; diagnostics?: DiagnosticsAPI; + clientAuth?: ClientAuthAPI; tools: ToolsAPI; editor?: EditorAPI; vscode?: VSCodeAPI; diff --git a/packages/ui/src/lib/connectionPayload.ts b/packages/ui/src/lib/connectionPayload.ts new file mode 100644 index 00000000..4c636afc --- /dev/null +++ b/packages/ui/src/lib/connectionPayload.ts @@ -0,0 +1,59 @@ +export type ClientConnectionPayload = { + v: 1; + serverUrl: string; + token: string; + label?: string; +}; + +export const buildClientConnectionPayload = (input: { + serverUrl: string; + token: string; + label?: string | null; +}): ClientConnectionPayload => ({ + v: 1, + serverUrl: input.serverUrl.trim().replace(/\/+$/, ''), + token: input.token.trim(), + ...(input.label?.trim() ? { label: input.label.trim() } : {}), +}); + +export const encodeClientConnectionPayload = (payload: ClientConnectionPayload): string => { + const params = new URLSearchParams(); + params.set('v', String(payload.v)); + params.set('server', payload.serverUrl); + params.set('token', payload.token); + if (payload.label) params.set('label', payload.label); + return `openchamber://connect?${params.toString()}`; +}; + +export const parseClientConnectionPayload = (value: string): ClientConnectionPayload | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + const url = new URL(trimmed); + if (url.protocol !== 'openchamber:' || url.hostname !== 'connect') { + return null; + } + const version = url.searchParams.get('v'); + const serverUrl = url.searchParams.get('server')?.trim() || ''; + const token = url.searchParams.get('token')?.trim() || ''; + const label = url.searchParams.get('label')?.trim() || ''; + + if (version !== '1' || !serverUrl || !token) { + return null; + } + + try { + const parsedServer = new URL(serverUrl); + if (parsedServer.protocol !== 'http:' && parsedServer.protocol !== 'https:') { + return null; + } + } catch { + return null; + } + + return buildClientConnectionPayload({ serverUrl, token, label }); + } catch { + return null; + } +}; diff --git a/packages/ui/src/lib/contextFileOpenGuard.ts b/packages/ui/src/lib/contextFileOpenGuard.ts index 218aa6c4..b2d4a15a 100644 --- a/packages/ui/src/lib/contextFileOpenGuard.ts +++ b/packages/ui/src/lib/contextFileOpenGuard.ts @@ -1,5 +1,6 @@ import type { FilesAPI } from '@/lib/api/types'; import { MAX_OPEN_FILE_LINES, countLinesWithLimit } from '@/lib/fileOpenLimits'; +import { runtimeFetch } from '@/lib/runtime-fetch'; export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable'; @@ -31,7 +32,7 @@ const readFileContent = async (files: FilesAPI, path: string): Promise = } const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true', optional: 'true' }); - const response = await fetch(`/api/fs/read?${params.toString()}`, { + const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { // Avoid conditional requests (304 + empty body). cache: 'no-store', }); diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index 144afb4d..7903e854 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -8,6 +8,8 @@ import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard'; import { getSyncSessions, getSyncMessages, getSyncParts } from '@/sync/sync-refs'; import { useStreamingStore } from '@/sync/streaming'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; export interface DebugMessageInfo { messageId: string; @@ -218,9 +220,7 @@ export const debugUtils = { } })(); - const runtimeApis = typeof window !== 'undefined' - ? (window as any).__OPENCHAMBER_RUNTIME_APIS__ - : null; + const runtimeApis = getRegisteredRuntimeAPIs(); const isTauriShell = typeof window !== 'undefined' && Boolean((window as any).__TAURI__); const safeJson = async (resp: Response) => { @@ -241,7 +241,7 @@ export const debugUtils = { const safeFetchJson = async (url: string): Promise => { try { - const resp = await fetch(url); + const resp = await runtimeFetch(url); return resp.ok ? await safeJson(resp) : { status: resp.status }; } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; @@ -253,20 +253,28 @@ export const debugUtils = { let settingsInfo: unknown = null; let opencodeHealth: unknown = null; - const pathUrl = currentDirectory - ? `/api/path?directory=${encodeURIComponent(currentDirectory)}` - : '/api/path'; - pathInfo = await safeFetchJson(pathUrl); + try { + const pathResult = await opencodeClient.getSdkClient().path.get( + currentDirectory ? { directory: currentDirectory } : undefined + ); + pathInfo = pathResult.error ? { error: pathResult.error } : pathResult.data; + } catch (error) { + pathInfo = { error: error instanceof Error ? error.message : String(error) }; + } - const projectUrl = currentDirectory - ? `/api/project/current?directory=${encodeURIComponent(currentDirectory)}` - : '/api/project/current'; - projectInfo = await safeFetchJson(projectUrl); + try { + const projectResult = await opencodeClient.getSdkClient().project.current( + currentDirectory ? { directory: currentDirectory } : undefined + ); + projectInfo = projectResult.error ? { error: projectResult.error } : projectResult.data; + } catch (error) { + projectInfo = { error: error instanceof Error ? error.message : String(error) }; + } settingsInfo = await safeFetchJson('/api/config/settings'); try { - const resp = await fetch('/api/health'); + const resp = await runtimeFetch('/api/health'); const contentType = resp.headers.get('content-type') || ''; const body = await safeText(resp); const isJson = contentType.toLowerCase().includes('application/json'); diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index a64f3d99..bf52b185 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -1,6 +1,9 @@ import type { ProjectEntry } from '@/lib/api/types'; -import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; +import { getInjectedBootOutcome } from '@/lib/desktopBoot'; import type { DraftStarterRef } from '@/lib/draftStarters'; +import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; +import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; export type AssistantNotificationPayload = { title?: string; @@ -307,8 +310,34 @@ export const isDesktopLocalOriginActive = (): boolean => { if (typeof window === 'undefined') return false; if (!isDesktopShell()) return false; + if (getRuntimeKey() === 'local') { + return true; + } + const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : ''; const localUrl = parseUrl(local); + const runtimeApiUrl = parseUrl(getRuntimeApiBaseUrl()); + + if (!runtimeApiUrl && localUrl && getInjectedBootOutcome()?.target === 'local') { + return true; + } + + if (localUrl && runtimeApiUrl) { + if (localUrl.origin === runtimeApiUrl.origin) { + return true; + } + + const localPort = localUrl.port || (localUrl.protocol === 'https:' ? '443' : '80'); + const runtimePort = runtimeApiUrl.port || (runtimeApiUrl.protocol === 'https:' ? '443' : '80'); + + return ( + localUrl.protocol === runtimeApiUrl.protocol && + localPort === runtimePort && + isLoopbackHost(localUrl.hostname) && + isLoopbackHost(runtimeApiUrl.hostname) + ); + } + const currentUrl = parseUrl(window.location.origin); if (localUrl && currentUrl) { @@ -357,14 +386,12 @@ export const startDesktopWindowDrag = async (): Promise => { }; export const isVSCodeRuntime = (): boolean => { - if (typeof window === "undefined") return false; - const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__; + const apis = getRegisteredRuntimeAPIs(); return apis?.runtime?.isVSCode === true; }; export const isWebRuntime = (): boolean => { - if (typeof window === "undefined") return false; - const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { platform?: string } } }).__OPENCHAMBER_RUNTIME_APIS__; + const apis = getRegisteredRuntimeAPIs(); const platform = apis?.runtime?.platform; if (platform === 'web') { return true; @@ -476,7 +503,7 @@ export const sendAssistantCompletionNotification = async ( }; export const checkForDesktopUpdates = async (): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!isTauriShell()) { return null; } @@ -493,7 +520,7 @@ export const checkForDesktopUpdates = async (): Promise => { export const downloadDesktopUpdate = async ( onProgress?: (progress: UpdateProgress) => void ): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!isTauriShell()) { return false; } @@ -553,7 +580,7 @@ export const downloadDesktopUpdate = async ( }; export const restartToApplyUpdate = async (): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!isTauriShell()) { return false; } diff --git a/packages/ui/src/lib/desktopBoot.test.ts b/packages/ui/src/lib/desktopBoot.test.ts index febd0b24..8efb5590 100644 --- a/packages/ui/src/lib/desktopBoot.test.ts +++ b/packages/ui/src/lib/desktopBoot.test.ts @@ -63,6 +63,20 @@ describe('resolveDesktopBootView', () => { ).toEqual({ screen: 'recovery', variant: 'remote-wrong-service', hostId: 'bad-host', url: 'https://bad.test' }); }); + test('returns recovery-remote for incompatible remote', () => { + expect( + resolveDesktopBootView({ + isDesktopShell: true, + bootOutcome: { + target: 'remote', + status: 'incompatible', + hostId: 'old-host', + url: 'https://old.test', + }, + }), + ).toEqual({ screen: 'recovery', variant: 'remote-incompatible', hostId: 'old-host', url: 'https://old.test' }); + }); + test('returns recovery view for local unreachable', () => { expect( resolveDesktopBootView({ diff --git a/packages/ui/src/lib/desktopBoot.ts b/packages/ui/src/lib/desktopBoot.ts index adb074f1..ca9d2405 100644 --- a/packages/ui/src/lib/desktopBoot.ts +++ b/packages/ui/src/lib/desktopBoot.ts @@ -29,6 +29,7 @@ export type DesktopBootOutcome = // Recovery screens - something is wrong | { target: 'local'; status: 'unreachable' } | { target: 'remote'; status: 'unreachable'; hostId: string; url: string } + | { target: 'remote'; status: 'incompatible'; hostId: string; url: string } | { target: 'remote'; status: 'wrong-service'; hostId: string; url: string } | { target: 'remote'; status: 'missing'; hostId: string }; @@ -40,6 +41,7 @@ export type DesktopBootView = | { screen: 'chooser' } | { screen: 'recovery'; variant: 'local-unavailable' } | { screen: 'recovery'; variant: 'remote-unreachable'; hostId: string; url: string } + | { screen: 'recovery'; variant: 'remote-incompatible'; hostId: string; url: string } | { screen: 'recovery'; variant: 'remote-wrong-service'; hostId: string; url: string } | { screen: 'recovery'; variant: 'remote-missing'; hostId: string }; @@ -56,7 +58,7 @@ export type DesktopBootViewInput = { const VALID_TARGETS = ['local', 'remote', null] as const; /** Valid status values */ -const VALID_STATUSES = ['ok', 'not-configured', 'unreachable', 'wrong-service', 'missing'] as const; +const VALID_STATUSES = ['ok', 'not-configured', 'unreachable', 'incompatible', 'wrong-service', 'missing'] as const; /** Return type for `validateBootOutcome`. */ type ValidationResult = @@ -115,12 +117,12 @@ function validateBootOutcome(raw: unknown): ValidationResult { } } - if (status === 'wrong-service') { + if (status === 'incompatible' || status === 'wrong-service') { if (target !== 'remote') return { valid: false }; if (typeof record.hostId !== 'string' || typeof record.url !== 'string') { return { valid: false }; } - return { valid: true, outcome: { target: 'remote', status: 'wrong-service', hostId: record.hostId, url: record.url } }; + return { valid: true, outcome: { target: 'remote', status, hostId: record.hostId, url: record.url } }; } if (status === 'missing') { @@ -187,6 +189,8 @@ export function resolveDesktopBootView( if (outcome.target === 'remote') { if (outcome.status === 'unreachable') { return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url }; + } else if (outcome.status === 'incompatible') { + return { screen: 'recovery', variant: 'remote-incompatible', hostId: outcome.hostId, url: outcome.url }; } else if (outcome.status === 'wrong-service') { return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url }; } else if (outcome.status === 'missing') { diff --git a/packages/ui/src/lib/desktopHosts.test.ts b/packages/ui/src/lib/desktopHosts.test.ts new file mode 100644 index 00000000..d5f4cc75 --- /dev/null +++ b/packages/ui/src/lib/desktopHosts.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import { redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts'; + +describe('resolveDesktopHostUrl', () => { + test('keeps regular host URLs unchanged', () => { + expect(resolveDesktopHostUrl('https://example.com/app?x=1')).toEqual({ + persistedUrl: 'https://example.com/app?x=1', + redeemUrl: null, + kind: 'normal-host', + }); + }); + + test('detects tunnel connect links and stores only origin', () => { + expect(resolveDesktopHostUrl('https://example.trycloudflare.com/connect?t=secret-token')).toEqual({ + persistedUrl: 'https://example.trycloudflare.com', + redeemUrl: 'https://example.trycloudflare.com/connect?t=secret-token', + kind: 'tunnel-connect-link', + }); + }); + + test('detects tunnel connect links with trailing slash', () => { + expect(resolveDesktopHostUrl('https://example.trycloudflare.com/connect/?t=secret-token#section')).toEqual({ + persistedUrl: 'https://example.trycloudflare.com', + redeemUrl: 'https://example.trycloudflare.com/connect/?t=secret-token', + kind: 'tunnel-connect-link', + }); + }); + + test('redacts tunnel tokens from labels', () => { + expect(redactSensitiveUrl('https://example.trycloudflare.com/connect?t=secret-token')).toBe( + 'https://example.trycloudflare.com/connect?t=%5BREDACTED%5D', + ); + }); +}); diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index 280ba08f..459d5d32 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -11,13 +11,19 @@ type TauriGlobal = { export type DesktopHost = { id: string; label: string; + /** Legacy/UI URL. During migration this may equal apiUrl. */ url: string; + /** API endpoint used by packaged Electron UI for this instance. */ + apiUrl?: string; + /** Remote client bearer token for packaged-client API access. */ + clientToken?: string; }; export type DesktopHostsConfig = { hosts: DesktopHost[]; defaultHostId: string | null; initialHostChoiceCompleted: boolean; + localOrigin?: string | null; }; /** Backward-compatible input type — callers may omit `initialHostChoiceCompleted`. */ @@ -25,14 +31,21 @@ export type DesktopHostsConfigInput = { hosts: DesktopHost[]; defaultHostId: string | null; initialHostChoiceCompleted?: boolean; + localClientToken?: string | null; }; export type HostProbeResult = { - status: 'ok' | 'auth' | 'wrong-service' | 'unreachable'; + status: 'ok' | 'auth' | 'update-recommended' | 'incompatible' | 'wrong-service' | 'unreachable'; latencyMs: number; }; -const SENSITIVE_QUERY_KEY = /token|auth|secret|api/i; +export type DesktopHostUrlResolution = { + persistedUrl: string; + redeemUrl: string | null; + kind: 'normal-host' | 'tunnel-connect-link'; +}; + +const SENSITIVE_QUERY_KEY = /^(t|.*(?:token|auth|secret|api).*)$/i; export const normalizeHostUrl = (raw: string): string | null => { const trimmed = raw.trim(); @@ -48,6 +61,31 @@ export const normalizeHostUrl = (raw: string): string | null => { } }; +export const resolveDesktopHostUrl = (raw: string): DesktopHostUrlResolution | null => { + const normalized = normalizeHostUrl(raw); + if (!normalized) return null; + + try { + const url = new URL(normalized); + const pathname = url.pathname.replace(/\/+$/, '') || '/'; + if (pathname === '/connect' && url.searchParams.has('t')) { + return { + persistedUrl: url.origin, + redeemUrl: url.toString(), + kind: 'tunnel-connect-link', + }; + } + } catch { + return null; + } + + return { + persistedUrl: normalized, + redeemUrl: null, + kind: 'normal-host', + }; +}; + export const redactSensitiveUrl = (raw: string): string => { const normalized = normalizeHostUrl(raw); if (!normalized) { @@ -122,8 +160,20 @@ const parseHost = (value: unknown): DesktopHost | null => { const id = readString(value, 'id'); const label = readString(value, 'label'); const url = readString(value, 'url'); + const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url'); + const clientToken = readString(value, 'clientToken') || readString(value, 'client_token'); if (!id || !label || !url) return null; - return { id, label, url }; + return { + id, + label, + url, + ...(apiUrl ? { apiUrl } : {}), + ...(clientToken ? { clientToken } : {}), + }; +}; + +export const getDesktopHostApiUrl = (host: DesktopHost): string => { + return normalizeHostUrl(host.apiUrl || host.url) || host.apiUrl || host.url; }; const getInvoke = (): TauriInvoke | null => { @@ -155,36 +205,48 @@ export const desktopHostsGet = async (): Promise => { const initialHostChoiceCompleted = raw.initialHostChoiceCompleted === true || raw.initial_host_choice_completed === true; + const localOrigin = readString(raw, 'localOrigin') || readString(raw, 'local_origin'); - return { hosts, defaultHostId, initialHostChoiceCompleted }; + return { hosts, defaultHostId, initialHostChoiceCompleted, localOrigin }; }; export const desktopHostsSet = async (config: DesktopHostsConfigInput): Promise => { const invoke = getInvoke(); if (!invoke) return; + const input: Record = { + hosts: config.hosts, + defaultHostId: config.defaultHostId, + initialHostChoiceCompleted: config.initialHostChoiceCompleted, + }; + if (config.localClientToken !== undefined) { + input.localClientToken = config.localClientToken; + } await invoke('desktop_hosts_set', { - input: { - hosts: config.hosts, - defaultHostId: config.defaultHostId, - initialHostChoiceCompleted: config.initialHostChoiceCompleted, - }, + input, }); }; -export const desktopHostProbe = async (url: string): Promise => { +export const desktopLocalClientTokenGet = async (): Promise => { + const invoke = getInvoke(); + if (!invoke) return ''; + const raw = await invoke('desktop_local_client_token_get').catch(() => null); + return typeof raw === 'string' ? raw.trim() : ''; +}; + +export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null }): Promise => { const invoke = getInvoke(); if (!invoke) { return { status: 'unreachable', latencyMs: 0 }; } - const raw = await invoke('desktop_host_probe', { url }); + const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined }); if (!isRecord(raw)) { return { status: 'unreachable', latencyMs: 0 }; } const rawStatus = raw.status; const status: HostProbeResult['status'] = - rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'wrong-service' || rawStatus === 'unreachable' + rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'update-recommended' || rawStatus === 'incompatible' || rawStatus === 'wrong-service' || rawStatus === 'unreachable' ? rawStatus : 'unreachable'; @@ -192,8 +254,8 @@ export const desktopHostProbe = async (url: string): Promise => return { status, latencyMs }; }; -export const desktopOpenNewWindowAtUrl = async (url: string): Promise => { +export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null }): Promise => { const invoke = getInvoke(); if (!invoke) return; - await invoke('desktop_new_window_at_url', { url }); + await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined }); }; diff --git a/packages/ui/src/lib/detectDevServer.ts b/packages/ui/src/lib/detectDevServer.ts index 17757e8a..38005081 100644 --- a/packages/ui/src/lib/detectDevServer.ts +++ b/packages/ui/src/lib/detectDevServer.ts @@ -1,5 +1,6 @@ import type { OpenChamberProjectAction } from './openchamberConfig'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { runtimeFetch } from '@/lib/runtime-fetch'; type DevServerInfo = { command: string; @@ -76,13 +77,13 @@ export async function detectDevServerCommand( async function hasStaticIndexHtml(directory: string): Promise { const target = `${directory}/index.html`; - const content = await readOptionalTextFile(target); + const content = await readOptionalTextFile(target, directory); return typeof content === 'string' && content.trim().length > 0; } async function allocatePreviewPort(): Promise { try { - const response = await fetch('/api/system/free-port', { cache: 'no-store' }); + const response = await runtimeFetch('/api/system/free-port', { cache: 'no-store' }); if (!response.ok) return null; const body = await response.json().catch(() => null) as { port?: unknown } | null; const port = typeof body?.port === 'number' ? body.port : null; @@ -133,7 +134,7 @@ function findDevScript(scripts: Record): string | null { * For server-side operations, the server's package-manager.js is used. */ async function detectPackageManager(directory: string): Promise { - const packageJsonContent = await readOptionalTextFile(`${directory}/package.json`); + const packageJsonContent = await readOptionalTextFile(`${directory}/package.json`, directory); if (packageJsonContent) { try { const pkg = JSON.parse(packageJsonContent) as { packageManager?: unknown }; @@ -156,7 +157,7 @@ async function detectPackageManager(directory: string): Promise ]; for (const [fileName, packageManager] of lockfiles) { - const content = await readOptionalTextFile(`${directory}/${fileName}`); + const content = await readOptionalTextFile(`${directory}/${fileName}`, directory); if (typeof content === 'string' && content.trim().length > 0) { return packageManager; } @@ -165,7 +166,21 @@ async function detectPackageManager(directory: string): Promise return 'npm'; } -async function readOptionalTextFile(path: string): Promise { +async function readOptionalTextFile(path: string, directory?: string): Promise { + if (directory?.trim()) { + try { + const params = new URLSearchParams({ path, directory, optional: 'true' }); + const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { + cache: 'no-store', + }); + if (response.ok) { + return response.text(); + } + } catch { + // Fall through to the registered files API for runtimes that do not expose HTTP fs routes. + } + } + const runtimeFiles = getRegisteredRuntimeAPIs()?.files; if (runtimeFiles?.readFile) { try { @@ -177,7 +192,7 @@ async function readOptionalTextFile(path: string): Promise { } try { - const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, { + const response = await runtimeFetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, { cache: 'no-store', }); if (!response.ok) return null; @@ -192,12 +207,14 @@ async function readOptionalTextFile(path: string): Promise { */ export async function readPackageJsonScripts(directory: string): Promise | null> { try { - const content = await readOptionalTextFile(`${directory}/package.json`); + const content = await readOptionalTextFile(`${directory}/package.json`, directory); if (content == null) return null; const pkg = JSON.parse(content); - - return pkg.scripts || null; + const scripts = (pkg as { scripts?: unknown }).scripts; + return scripts && typeof scripts === 'object' && !Array.isArray(scripts) + ? scripts as Record + : null; } catch { return null; } diff --git a/packages/ui/src/lib/execCommands.ts b/packages/ui/src/lib/execCommands.ts index e6bf19ce..4ad67de0 100644 --- a/packages/ui/src/lib/execCommands.ts +++ b/packages/ui/src/lib/execCommands.ts @@ -1,4 +1,6 @@ -import type { CommandExecResult, FilesAPI, RuntimeAPIs } from '@/lib/api/types'; +import type { CommandExecResult, FilesAPI } from '@/lib/api/types'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { runtimeFetch } from '@/lib/runtime-fetch'; type ExecResult = { success: boolean; results: CommandExecResult[] }; @@ -12,8 +14,7 @@ const getBaseUrl = (): string => { }; function getRuntimeFilesAPI(): FilesAPI | null { - if (typeof window === 'undefined') return null; - const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__; + const apis = getRegisteredRuntimeAPIs(); if (apis?.files) { return apis.files; } @@ -26,7 +27,7 @@ export async function execCommands(commands: string[], cwd: string): Promise { - if (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTIME_APIS__?.git) { - return window.__OPENCHAMBER_RUNTIME_APIS__.git; - } - return null; + return getRegisteredRuntimeAPIs()?.git ?? null; }; const requestChatForceScrollBottom = (sessionId: string) => { diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index c136059e..be79c6ad 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -35,33 +35,12 @@ import type { RevertCommitResponse, ResetToCommitResponse, } from './api/types'; - -declare global { - interface Window { - __OPENCHAMBER_DESKTOP_SERVER__?: { - origin: string; - opencodePort: number | null; - apiPrefix: string; - cliAvailable: boolean; - }; - } -} - -const resolveBaseOrigin = (): string => { - if (typeof window === 'undefined') { - return ''; - } - const desktopOrigin = window.__OPENCHAMBER_DESKTOP_SERVER__?.origin; - if (desktopOrigin) { - return desktopOrigin; - } - return window.location.origin; -}; +import { runtimeFetch } from './runtime-fetch'; +import { getRuntimeUrlResolver } from './runtime-url'; const API_BASE = '/api/git'; const GIT_STATUS_CACHE_TTL_MS = 1200; const GIT_REPO_CHECK_CACHE_TTL_MS = 5000; - const gitStatusCache = new Map(); const gitStatusInFlight = new Map>(); const gitRepoCache = new Map(); @@ -74,19 +53,10 @@ function buildUrl( directory: string | null | undefined, params?: Record ): string { - const url = new URL(path, resolveBaseOrigin()); - if (directory) { - url.searchParams.set('directory', directory); - } + const query: Record = { ...params }; + if (directory) query.directory = directory; - if (params) { - for (const [key, value] of Object.entries(params)) { - if (value === undefined) continue; - url.searchParams.set(key, String(value)); - } - } - - return url.toString(); + return getRuntimeUrlResolver().api(path, query); } export async function checkIsGitRepository(directory: string): Promise { @@ -103,7 +73,7 @@ export async function checkIsGitRepository(directory: string): Promise } const task = (async () => { - const response = await fetch(buildUrl(`${API_BASE}/check`, directory)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/check`, directory)); if (!response.ok) { throw new Error(`Failed to check git repository: ${response.statusText}`); } @@ -141,7 +111,7 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light' } const task = (async () => { - const response = await fetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined)); if (!response.ok) { throw new Error(`Failed to get git status: ${response.statusText}`); } @@ -169,7 +139,7 @@ export async function getGitDiff(directory: string, options: GetGitDiffOptions): throw new Error('path is required to fetch git diff'); } - const response = await fetch( + const response = await runtimeFetch( buildUrl(`${API_BASE}/diff`, directory, { path, staged: staged ? 'true' : undefined, @@ -190,7 +160,7 @@ export async function getGitFileDiff(directory: string, options: GetGitFileDiffO throw new Error('path is required to fetch git file diff'); } - const response = await fetch( + const response = await runtimeFetch( buildUrl(`${API_BASE}/file-diff`, directory, { path, staged: staged ? 'true' : undefined, @@ -213,7 +183,7 @@ export async function revertGitFile( throw new Error('path is required to revert git changes'); } - const response = await fetch(buildUrl(`${API_BASE}/revert`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/revert`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: filePath, scope: options?.scope }), @@ -238,7 +208,7 @@ export async function stageGitFiles(directory: string, filePaths: string[]): Pro throw new Error('path is required to stage git changes'); } - const response = await fetch(buildUrl(`${API_BASE}/stage`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/stage`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }), @@ -261,7 +231,7 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P throw new Error('path is required to unstage git changes'); } - const response = await fetch(buildUrl(`${API_BASE}/unstage`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/unstage`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }), @@ -277,7 +247,7 @@ export async function isLinkedWorktree(directory: string): Promise { if (!directory) { return false; } - const response = await fetch(buildUrl(`${API_BASE}/worktree-type`, directory)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/worktree-type`, directory)); if (!response.ok) { throw new Error(`Failed to detect worktree type: ${response.statusText}`); } @@ -286,7 +256,7 @@ export async function isLinkedWorktree(directory: string): Promise { } export async function getGitBranches(directory: string): Promise { - const response = await fetch(buildUrl(`${API_BASE}/branches`, directory)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory)); if (!response.ok) { throw new Error(`Failed to get branches: ${response.statusText}`); } @@ -298,7 +268,7 @@ export async function deleteGitBranch(directory: string, payload: GitDeleteBranc throw new Error('branch is required to delete a branch'); } - const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -317,7 +287,7 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe throw new Error('branch is required to delete remote branch'); } - const response = await fetch(buildUrl(`${API_BASE}/remote-branches`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/remote-branches`, directory), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -337,7 +307,7 @@ export async function removeRemote(directory: string, payload: GitRemoveRemotePa throw new Error('remote is required to remove a remote'); } - const response = await fetch(buildUrl(`${API_BASE}/remotes`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/remotes`, directory), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ remote }), @@ -371,7 +341,7 @@ export async function generateCommitMessage( body.modelId = options.modelId; } - const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-message`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -438,7 +408,7 @@ export async function generatePullRequestDescription( requestBody.modelId = modelId; } - const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/pr-description`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), @@ -459,7 +429,7 @@ export async function generatePullRequestDescription( } export async function listGitWorktrees(directory: string): Promise { - const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory)); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to list worktrees'); @@ -468,7 +438,7 @@ export async function listGitWorktrees(directory: string): Promise { - const response = await fetch(buildUrl(`${API_BASE}/worktrees/validate`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/validate`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload ?? {}), @@ -483,7 +453,7 @@ export async function validateGitWorktree(directory: string, payload: CreateGitW } export async function getGitWorktreeBootstrapStatus(directory: string): Promise { - const response = await fetch(buildUrl(`${API_BASE}/worktrees/bootstrap-status`, directory)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/bootstrap-status`, directory)); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to get worktree bootstrap status'); @@ -492,7 +462,7 @@ export async function getGitWorktreeBootstrapStatus(directory: string): Promise< } export async function previewGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise { - const response = await fetch(buildUrl(`${API_BASE}/worktrees/preview`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/preview`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload ?? {}), @@ -507,7 +477,7 @@ export async function previewGitWorktree(directory: string, payload: CreateGitWo } export async function createGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise { - const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload ?? {}), @@ -522,7 +492,7 @@ export async function createGitWorktree(directory: string, payload: CreateGitWor } export async function deleteGitWorktree(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> { - const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload ?? {}), @@ -541,7 +511,7 @@ export async function createGitCommit( message: string, options: CreateGitCommitOptions = {} ): Promise { - const response = await fetch(buildUrl(`${API_BASE}/commit`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/commit`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -562,7 +532,7 @@ export async function gitPush( directory: string, options: { remote?: string; branch?: string; options?: string[] | Record } = {} ): Promise { - const response = await fetch(buildUrl(`${API_BASE}/push`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/push`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), @@ -578,7 +548,7 @@ export async function gitPull( directory: string, options: GitPullOptions = {} ): Promise { - const response = await fetch(buildUrl(`${API_BASE}/pull`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/pull`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), @@ -594,7 +564,7 @@ export async function gitFetch( directory: string, options: { remote?: string; branch?: string } = {} ): Promise<{ success: boolean }> { - const response = await fetch(buildUrl(`${API_BASE}/fetch`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/fetch`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), @@ -607,7 +577,7 @@ export async function gitFetch( } export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> { - const response = await fetch(buildUrl(`${API_BASE}/stashes`, directory)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/stashes`, directory)); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to list stashes'); @@ -616,7 +586,7 @@ export async function listGitStashes(directory: string): Promise<{ stashes: GitS } export async function countGitStashFiles(directory: string, refs: string[]): Promise<{ counts: Record }> { - const response = await fetch(buildUrl(`${API_BASE}/stashes/file-counts`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/stashes/file-counts`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refs }), @@ -629,7 +599,7 @@ export async function countGitStashFiles(directory: string, refs: string[]): Pro } export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> { - const response = await fetch(buildUrl(`${API_BASE}/stash`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/stash`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), @@ -642,7 +612,7 @@ export async function stashGitChanges(directory: string, options: { message?: st } const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => { - const response = await fetch(buildUrl(`${API_BASE}/${path}`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/${path}`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), @@ -659,7 +629,7 @@ export const popGitStash = (directory: string, options: { ref: string }) => post export const dropGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/drop', options); export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> { - const response = await fetch(buildUrl(`${API_BASE}/checkout`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/checkout`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ branch }), @@ -676,7 +646,7 @@ export async function createBranch( name: string, startPoint?: string ): Promise<{ success: boolean; branch: string }> { - const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, startPoint }), @@ -693,7 +663,7 @@ export async function renameBranch( oldName: string, newName: string ): Promise<{ success: boolean; branch: string }> { - const response = await fetch(buildUrl(`${API_BASE}/branches/rename`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/branches/rename`, directory), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oldName, newName }), @@ -709,7 +679,7 @@ export async function getGitLog( directory: string, options: GitLogOptions = {} ): Promise { - const response = await fetch( + const response = await runtimeFetch( buildUrl(`${API_BASE}/log`, directory, { maxCount: options.maxCount, from: options.from, @@ -729,7 +699,7 @@ export async function getCommitFiles( directory: string, hash: string ): Promise { - const response = await fetch( + const response = await runtimeFetch( buildUrl(`${API_BASE}/commit-files`, directory, { hash }) ); if (!response.ok) { @@ -744,7 +714,7 @@ export async function getCommitFileDiff( filePath: string, isBinary: boolean ): Promise { - const response = await fetch( + const response = await runtimeFetch( buildUrl(`${API_BASE}/commit-file-diff`, directory, { hash, path: filePath, @@ -758,7 +728,7 @@ export async function getCommitFileDiff( } export async function getGitIdentities(): Promise { - const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/identities`, undefined)); if (!response.ok) { throw new Error(`Failed to get git identities: ${response.statusText}`); } @@ -766,7 +736,7 @@ export async function getGitIdentities(): Promise { } export async function createGitIdentity(profile: GitIdentityProfile): Promise { - const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/identities`, undefined), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile), @@ -779,7 +749,7 @@ export async function createGitIdentity(profile: GitIdentityProfile): Promise { - const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), @@ -792,7 +762,7 @@ export async function updateGitIdentity(id: string, updates: GitIdentityProfile) } export async function deleteGitIdentity(id: string): Promise { - const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), { method: 'DELETE', }); if (!response.ok) { @@ -805,7 +775,7 @@ export async function getCurrentGitIdentity(directory: string): Promise { if (!directory) { return false; } - const response = await fetch(buildUrl(`${API_BASE}/has-local-identity`, directory)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/has-local-identity`, directory)); if (!response.ok) { throw new Error(`Failed to check local identity: ${response.statusText}`); } @@ -833,7 +803,7 @@ export async function hasLocalIdentity(directory: string): Promise { } export async function getGlobalGitIdentity(): Promise { - const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/global-identity`, undefined)); if (!response.ok) { throw new Error(`Failed to get global git identity: ${response.statusText}`); } @@ -852,7 +822,7 @@ export async function setGitIdentity( directory: string, profileId: string ): Promise<{ success: boolean; profile: GitIdentityProfile }> { - const response = await fetch(buildUrl(`${API_BASE}/set-identity`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/set-identity`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }), @@ -865,7 +835,7 @@ export async function setGitIdentity( } export async function discoverGitCredentials(): Promise { - const response = await fetch(buildUrl(`${API_BASE}/discover-credentials`, undefined)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/discover-credentials`, undefined)); if (!response.ok) { throw new Error(`Failed to discover git credentials: ${response.statusText}`); } @@ -876,7 +846,7 @@ export async function getRemoteUrl(directory: string, remote?: string): Promise< if (!directory) { return null; } - const response = await fetch(buildUrl(`${API_BASE}/remote-url`, directory, { remote })); + const response = await runtimeFetch(buildUrl(`${API_BASE}/remote-url`, directory, { remote })); if (!response.ok) { return null; } @@ -885,7 +855,7 @@ export async function getRemoteUrl(directory: string, remote?: string): Promise< } export async function getRemotes(directory: string): Promise> { - const response = await fetch(buildUrl(`${API_BASE}/remotes`, directory)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/remotes`, directory)); if (!response.ok) { throw new Error(`Failed to get remotes: ${response.statusText}`); } @@ -896,7 +866,7 @@ export async function rebase( directory: string, options: { onto: string } ): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> { - const response = await fetch(buildUrl(`${API_BASE}/rebase`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), @@ -909,7 +879,7 @@ export async function rebase( } export async function abortRebase(directory: string): Promise<{ success: boolean }> { - const response = await fetch(buildUrl(`${API_BASE}/rebase/abort`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase/abort`, directory), { method: 'POST', }); if (!response.ok) { @@ -923,7 +893,7 @@ export async function merge( directory: string, options: { branch: string } ): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> { - const response = await fetch(buildUrl(`${API_BASE}/merge`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/merge`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), @@ -939,7 +909,7 @@ export async function checkoutCommit( directory: string, hash: string ): Promise { - const response = await fetch(buildUrl(`${API_BASE}/checkout-commit`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/checkout-commit`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash }), @@ -955,7 +925,7 @@ export async function cherryPick( directory: string, hash: string ): Promise { - const response = await fetch(buildUrl(`${API_BASE}/cherry-pick`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/cherry-pick`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash }), @@ -971,7 +941,7 @@ export async function revertCommit( directory: string, hash: string ): Promise { - const response = await fetch(buildUrl(`${API_BASE}/revert-commit`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/revert-commit`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash }), @@ -989,7 +959,7 @@ export async function resetToCommit( mode: 'soft' | 'mixed' | 'hard', force?: boolean ): Promise { - const response = await fetch(buildUrl(`${API_BASE}/reset-to-commit`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/reset-to-commit`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash, mode, force }), @@ -1002,7 +972,7 @@ export async function resetToCommit( } export async function abortMerge(directory: string): Promise<{ success: boolean }> { - const response = await fetch(buildUrl(`${API_BASE}/merge/abort`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/merge/abort`, directory), { method: 'POST', }); if (!response.ok) { @@ -1013,7 +983,7 @@ export async function abortMerge(directory: string): Promise<{ success: boolean } export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { - const response = await fetch(buildUrl(`${API_BASE}/rebase/continue`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase/continue`, directory), { method: 'POST', }); if (!response.ok) { @@ -1024,7 +994,7 @@ export async function continueRebase(directory: string): Promise<{ success: bool } export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { - const response = await fetch(buildUrl(`${API_BASE}/merge/continue`, directory), { + const response = await runtimeFetch(buildUrl(`${API_BASE}/merge/continue`, directory), { method: 'POST', }); if (!response.ok) { @@ -1048,7 +1018,7 @@ export async function stashPop(directory: string): Promise<{ success: boolean }> } export async function getConflictDetails(directory: string): Promise { - const response = await fetch(buildUrl(`${API_BASE}/conflict-details`, directory)); + const response = await runtimeFetch(buildUrl(`${API_BASE}/conflict-details`, directory)); if (!response.ok) { throw new Error(`Failed to get conflict details: ${response.statusText}`); } @@ -1064,7 +1034,7 @@ export async function validateWorktreeDirectory( resolvedWorktreeRoot: string | null; resolvedCwd: string | null; }> { - const response = await fetch(`${API_BASE}/validate-directory`, { + const response = await runtimeFetch(`${API_BASE}/validate-directory`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ directory, worktreeRoot }), @@ -1087,7 +1057,7 @@ export async function canonicalizeWorktreeState( degraded: boolean; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; }> { - const response = await fetch(`${API_BASE}/canonicalize-worktree-state`, { + const response = await runtimeFetch(`${API_BASE}/canonicalize-worktree-state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ directory }), diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 2241efa7..119d4bf2 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -231,19 +231,50 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.sessionFusion': 'Fusion', 'settings.remoteInstances.sidebar.title': 'Remote Instances', 'settings.remoteInstances.sidebar.total': 'Total {count}', - 'settings.remoteInstances.sidebar.newSshInstanceName': 'New SSH Instance', - 'settings.remoteInstances.sidebar.actions.addSshInstance': 'Add SSH instance', + 'settings.remoteInstances.sidebar.newSshInstanceName': 'New SSH connection', + 'settings.remoteInstances.sidebar.actions.addSshInstance': 'Add SSH connection', 'settings.remoteInstances.sidebar.actions.connect': 'Connect', 'settings.remoteInstances.sidebar.actions.disconnect': 'Disconnect', 'settings.remoteInstances.sidebar.actions.retry': 'Retry', 'settings.remoteInstances.sidebar.actions.remove': 'Remove', 'settings.remoteInstances.sidebar.confirm.localPortInUseRetry': 'Local port is already in use. Pick a random free local port and retry?', - 'settings.remoteInstances.sidebar.toast.createFailed': 'Failed to create SSH instance', + 'settings.remoteInstances.sidebar.toast.createFailed': 'Failed to create SSH connection', 'settings.remoteInstances.sidebar.toast.retriedWithRandomPort': 'Retried with a random local port', 'settings.remoteInstances.sidebar.toast.connectFailed': 'Failed to connect instance', 'settings.remoteInstances.sidebar.toast.disconnectFailed': 'Failed to disconnect instance', 'settings.remoteInstances.sidebar.toast.retryFailed': 'Failed to retry connection', 'settings.remoteInstances.sidebar.toast.removeFailed': 'Failed to remove instance', + 'settings.remoteInstances.direct.sidebarTitle': 'Server links', + 'settings.remoteInstances.direct.sidebarDescription': 'Connect with a link or token', + 'settings.remoteInstances.direct.title': 'Other OpenChamber servers', + 'settings.remoteInstances.direct.description': 'Add another OpenChamber server by URL. Use this when the server is already running and you have a connection token.', + 'settings.remoteInstances.direct.field.labelPlaceholder': 'Label (optional)', + 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', + 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Connection token (optional for trusted local servers)', + 'settings.remoteInstances.direct.note': 'Connection tokens are saved on this device and used only when this app connects to that server.', + 'settings.remoteInstances.direct.actions.add': 'Add Server', + 'settings.remoteInstances.direct.import.description': 'Paste a connection link from another OpenChamber server.', + 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', + 'settings.remoteInstances.direct.import.action': 'Import Link', + 'settings.remoteInstances.direct.error.invalidConnectLink': 'Invalid OpenChamber connection link.', + 'settings.remoteInstances.direct.state.loading': 'Loading instances...', + 'settings.remoteInstances.direct.state.empty': 'No other servers added yet.', + 'settings.remoteInstances.clientAuth.title': 'Connect to this server', + 'settings.remoteInstances.clientAuth.description': 'Create a secure link or token so OpenChamber Desktop can connect to this server.', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Device name (optional)', + 'settings.remoteInstances.clientAuth.actions.create': 'Create Token', + 'settings.remoteInstances.clientAuth.actions.pair': 'Create Link', + 'settings.remoteInstances.clientAuth.actions.revoke': 'Revoke', + 'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Clear revoked', + 'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code', + 'settings.remoteInstances.clientAuth.pairingUrl': 'Connection link', + 'settings.remoteInstances.clientAuth.createdToken': 'Copy this token now. For security, it will not be shown again.', + 'settings.remoteInstances.clientAuth.state.loading': 'Loading tokens...', + 'settings.remoteInstances.clientAuth.state.empty': 'No devices connected yet.', + 'settings.remoteInstances.clientAuth.state.revoked': 'Revoked', + 'settings.remoteInstances.clientAuth.state.thisDevice': 'This device', + 'settings.remoteInstances.clientAuth.lastUsed': 'Last used {date}', + 'settings.remoteInstances.clientAuth.neverUsed': 'Never used', 'settings.remoteInstances.sidebar.phase.ready': 'Ready', 'settings.remoteInstances.sidebar.phase.error': 'Error', 'settings.remoteInstances.sidebar.phase.reconnect': 'Reconnect', @@ -254,20 +285,20 @@ export const settingsDict = { 'settings.remoteInstances.sidebar.phase.connecting': 'Connecting', 'settings.remoteInstances.sidebar.phase.idle': 'Idle', 'settings.remoteInstances.page.section.instance': 'Instance', - 'settings.remoteInstances.page.section.instanceDescription': 'Core SSH settings.', + 'settings.remoteInstances.page.section.instanceDescription': 'Choose the SSH command and a display name for this connection.', 'settings.remoteInstances.page.field.mode': 'Mode', - 'settings.remoteInstances.page.field.modeHint': 'Managed installs/updates and starts OpenChamber remotely. External assumes it is already running.', + 'settings.remoteInstances.page.field.modeHint': 'Choose whether OpenChamber should start the server for you, or connect to one that is already running.', 'settings.remoteInstances.page.field.modePlaceholder': 'Select mode', - 'settings.remoteInstances.page.field.modeManaged': 'Managed (auto start)', - 'settings.remoteInstances.page.field.modeExternal': 'External (already running)', + 'settings.remoteInstances.page.field.modeManaged': 'Start it for me', + 'settings.remoteInstances.page.field.modeExternal': 'Already running', 'settings.remoteInstances.page.field.preferredRemotePort': 'Preferred remote port', - 'settings.remoteInstances.page.field.preferredRemotePortHint': 'Port OpenChamber should use on the remote host. Leave empty to let the runtime choose.', + 'settings.remoteInstances.page.field.preferredRemotePortHint': 'Port to use on the remote machine. Leave empty to choose one automatically.', 'settings.remoteInstances.page.field.keepServerRunning': 'Keep server running', - 'settings.remoteInstances.page.field.keepServerRunningHint': 'If enabled, OpenChamber daemon is left running remotely when you disconnect.', + 'settings.remoteInstances.page.field.keepServerRunningHint': 'Keep OpenChamber running on the remote machine after you disconnect.', 'settings.remoteInstances.page.field.bindHost': 'Bind host', - 'settings.remoteInstances.page.field.bindHostHint': 'Network interface for the main local URL. Use 127.0.0.1/localhost for local-only access.', + 'settings.remoteInstances.page.field.bindHostHint': 'Where the local connection should listen. Use 127.0.0.1 or localhost unless you need LAN access.', 'settings.remoteInstances.page.field.preferredLocalPort': 'Preferred local port', - 'settings.remoteInstances.page.field.preferredLocalPortHint': 'Preferred local port for the main OpenChamber tunnel. Leave empty for auto-select.', + 'settings.remoteInstances.page.field.preferredLocalPortHint': 'Local port to open for this connection. Leave empty to choose one automatically.', 'settings.remoteInstances.page.field.forwardType': 'Forward type', 'settings.remoteInstances.page.field.localHostPlaceholder': '127.0.0.1', 'settings.remoteInstances.page.field.remoteHostPlaceholder': '127.0.0.1', @@ -276,7 +307,7 @@ export const settingsDict = { 'settings.remoteInstances.page.preview.localSocks5': '(local SOCKS5)', 'settings.remoteInstances.page.preview.local': '(local)', 'settings.remoteInstances.page.preview.remote': '(remote)', - 'settings.remoteInstances.page.toast.openLocalEndpointFailed': 'Failed to open local endpoint', + 'settings.remoteInstances.page.toast.openLocalEndpointFailed': 'Failed to open local address', 'settings.remoteInstances.page.toast.localUrlCopied': 'Local URL copied', 'settings.remoteInstances.page.actions.copyLocalUrl': 'Copy local URL', 'settings.remoteInstances.page.actions.open': 'Open', @@ -978,26 +1009,26 @@ export const settingsDict = { 'settings.usage.pace.waitSeparator': ' · Wait ', 'settings.usage.pace.predictionLabel': 'Pred: ', 'settings.remoteInstances.page.title': 'Remote Instance', - 'settings.remoteInstances.page.description': 'Configure SSH connection, remote server, and forwarding settings.', + 'settings.remoteInstances.page.description': 'Connect to another machine over SSH and open OpenChamber there.', 'settings.remoteInstances.page.empty.selectInstance': 'Select an instance to view and edit its settings.', 'settings.remoteInstances.page.empty.noExtraForwards': 'No extra port forwards configured.', 'settings.remoteInstances.page.section.actions': 'Actions', - 'settings.remoteInstances.page.section.actionsDescription': 'Connect, reconnect, inspect logs, or remove this instance.', - 'settings.remoteInstances.page.section.remoteServer': 'Remote Server', - 'settings.remoteInstances.page.section.remoteServerDescription': 'How OpenChamber is managed and started on the remote host.', - 'settings.remoteInstances.page.section.mainTunnel': 'Main Tunnel', - 'settings.remoteInstances.page.section.mainTunnelDescription': 'Primary local endpoint for this remote instance.', + 'settings.remoteInstances.page.section.actionsDescription': 'Connect, reconnect, view logs, or remove this connection.', + 'settings.remoteInstances.page.section.remoteServer': 'OpenChamber on the remote machine', + 'settings.remoteInstances.page.section.remoteServerDescription': 'Choose how OpenChamber should run after SSH connects.', + 'settings.remoteInstances.page.section.mainTunnel': 'Local access', + 'settings.remoteInstances.page.section.mainTunnelDescription': 'Choose the local address used to open this remote OpenChamber server.', 'settings.remoteInstances.page.section.authentication': 'Authentication', 'settings.remoteInstances.page.section.authenticationDescription': 'Optional credentials for SSH and the remote OpenChamber UI.', 'settings.remoteInstances.page.section.portForwards': 'Port Forwards', - 'settings.remoteInstances.page.section.portForwardsDescription': 'Additional SSH forwards beyond the main tunnel.', + 'settings.remoteInstances.page.section.portForwardsDescription': 'Optional extra ports to make available through this SSH connection.', 'settings.remoteInstances.page.field.sshCommand': 'SSH command', 'settings.remoteInstances.page.field.sshCommandPlaceholder': 'ssh user@host', 'settings.remoteInstances.page.field.nickname': 'Nickname', - 'settings.remoteInstances.page.field.nicknamePlaceholder': 'My remote host', + 'settings.remoteInstances.page.field.nicknamePlaceholder': 'Work laptop', 'settings.remoteInstances.page.field.connectionTimeoutSeconds': 'Connection timeout (seconds)', 'settings.remoteInstances.page.field.installMethod': 'Install method', - 'settings.remoteInstances.page.field.installMethodHint': 'How OpenChamber is installed when running in managed mode.', + 'settings.remoteInstances.page.field.installMethodHint': 'How OpenChamber should be placed on the remote machine when this app starts it for you.', 'settings.remoteInstances.page.field.selectInstallMethodPlaceholder': 'Select install method', 'settings.remoteInstances.page.field.installMethodDownloadRelease': 'Download release', 'settings.remoteInstances.page.field.installMethodUploadBundle': 'Upload bundle', @@ -1006,14 +1037,14 @@ export const settingsDict = { 'settings.remoteInstances.page.field.sshPasswordPlaceholder': 'Enter SSH password', 'settings.remoteInstances.page.field.uiPasswordOptional': 'UI password (optional)', 'settings.remoteInstances.page.field.uiPasswordPlaceholder': 'Enter UI password', - 'settings.remoteInstances.page.field.forwardTypeHint': 'Choose local (-L), remote (-R), or dynamic (-D) forwarding.', + 'settings.remoteInstances.page.field.forwardTypeHint': 'Choose what kind of port access this SSH connection should provide.', 'settings.remoteInstances.page.field.typePlaceholder': 'Type', 'settings.remoteInstances.page.forwardType.local': 'Local (-L)', 'settings.remoteInstances.page.forwardType.remote': 'Remote (-R)', 'settings.remoteInstances.page.forwardType.dynamic': 'Dynamic (-D)', - 'settings.remoteInstances.page.forwardTypeDescription.local': 'Forward local traffic to a remote destination.', - 'settings.remoteInstances.page.forwardTypeDescription.remote': 'Expose a remote endpoint and forward it back to your local machine.', - 'settings.remoteInstances.page.forwardTypeDescription.dynamic': 'Expose a local SOCKS5 proxy over SSH.', + 'settings.remoteInstances.page.forwardTypeDescription.local': 'Open a local port that connects to something on the remote machine.', + 'settings.remoteInstances.page.forwardTypeDescription.remote': 'Open a port on the remote machine that connects back to your computer.', + 'settings.remoteInstances.page.forwardTypeDescription.dynamic': 'Open a local SOCKS proxy through the SSH connection.', 'settings.remoteInstances.page.actions.create': 'Create', 'settings.remoteInstances.page.actions.cancel': 'Cancel', 'settings.remoteInstances.page.actions.connecting': 'Connecting...', @@ -1024,7 +1055,7 @@ export const settingsDict = { 'settings.remoteInstances.page.actions.enableForwardAria': 'Enable forward', 'settings.remoteInstances.page.actions.openLocal': 'Open local', 'settings.remoteInstances.page.actions.addForward': 'Add forward', - 'settings.remoteInstances.page.import.sectionTitle': 'Import from SSH config', + 'settings.remoteInstances.page.import.sectionTitle': 'Saved SSH hosts', 'settings.remoteInstances.page.import.loading': 'Loading SSH hosts...', 'settings.remoteInstances.page.import.noneFound': 'No SSH hosts found.', 'settings.remoteInstances.page.import.noneAvailable': 'No SSH hosts available to import.', @@ -1034,12 +1065,12 @@ export const settingsDict = { 'settings.remoteInstances.page.logsDialog.title': 'SSH Logs', 'settings.remoteInstances.page.logsDialog.loading': 'Loading logs...', 'settings.remoteInstances.page.logsDialog.empty': 'No SSH logs yet.', - 'settings.remoteInstances.page.patternDialog.title': 'Create from wildcard pattern', + 'settings.remoteInstances.page.patternDialog.title': 'Choose an SSH destination', 'settings.remoteInstances.page.patternDialog.destinationPlaceholder': 'user@host', 'settings.remoteInstances.page.phase.resolvingConfiguration': 'Resolving configuration', 'settings.remoteInstances.page.phase.checkingAuth': 'Checking authentication', 'settings.remoteInstances.page.phase.establishingSsh': 'Establishing SSH connection', - 'settings.remoteInstances.page.phase.probingRemote': 'Probing remote host', + 'settings.remoteInstances.page.phase.probingRemote': 'Checking remote machine', 'settings.remoteInstances.page.phase.installingOpenChamber': 'Installing OpenChamber', 'settings.remoteInstances.page.phase.updatingOpenChamber': 'Updating OpenChamber', 'settings.remoteInstances.page.phase.detectingServer': 'Detecting server', @@ -1504,6 +1535,9 @@ export const settingsDict = { 'settings.voice.page.preview.voiceLine': 'Hello! I\'m {voiceName}. This is how I sound.', 'settings.voice.page.preview.customServerLine': 'Hello! This is a preview of the custom TTS server.', 'settings.openchamber.visual.section.colorMode': 'Color Mode', + 'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout', + 'settings.openchamber.visual.option.mobileLayout.default': 'Default', + 'settings.openchamber.visual.option.mobileLayout.new': 'New', 'settings.openchamber.visual.section.localization': 'Localization', 'settings.openchamber.visual.section.spacingAndLayout': 'Spacing & Layout', 'settings.openchamber.visual.section.navigation': 'Navigation', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 3839be7d..fbcdb275 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -26,6 +26,94 @@ export const dict = { 'layout.mainTab.files': 'Files', 'layout.mainTab.terminal': 'Terminal', 'layout.mainTab.context': 'Context', + 'mobile.nav.aria': 'Mobile navigation', + 'mobile.nav.changes': 'Changes', + 'mobile.nav.settings': 'Settings', + 'mobile.surface.closeAria': 'Close', + 'mobile.header.openMenuAria': 'Open menu', + 'mobile.menu.titleAria': 'Workspace tools', + 'mobile.menu.files': 'Files', + 'mobile.menu.changes': 'Changes', + 'mobile.menu.settings': 'Settings', + 'mobile.sessions.newChatCta': 'New chat in {project}', + 'mobile.sessions.dateGroup.today': 'Today', + 'mobile.sessions.dateGroup.yesterday': 'Yesterday', + 'mobile.sessions.dateGroup.thisWeek': 'Earlier this week', + 'mobile.sessions.dateGroup.older': 'Older', + 'mobile.sessions.section.worktrees': 'Worktrees', + 'mobile.sessions.section.otherProjects': 'Switch project', + 'mobile.sessions.section.projects': 'Projects', + 'mobile.sessions.empty.noProjectsTitle': 'No projects yet', + 'mobile.sessions.empty.noProjectsDescription': 'Add a project to start chatting with your code.', + 'mobile.sessions.empty.noSessionsTitle': 'No sessions yet', + 'mobile.sessions.empty.noSessionsDescription': 'Start your first chat to see it here.', + 'mobile.sessions.empty.searchTitle': 'No matches', + 'mobile.sessions.empty.searchDescription': 'Try a different search term.', + 'mobile.sessions.showArchived': 'Show archived ({count})', + 'mobile.sessions.hideArchived': 'Hide archived', + 'mobile.sessions.activeWorktreeAria': 'Active worktree', + 'mobile.sessions.activeProjectAria': 'Active project', + 'mobile.sessions.startNewChat': 'Start new chat', + 'mobile.sessions.newChat': 'New chat', + 'mobile.sessions.editOrder': 'Reorder projects', + 'mobile.sessions.doneEditing': 'Done', + 'mobile.sessions.editOrderHint': 'Drag the handle or use the arrows to reorder projects. Tap the check to finish.', + 'mobile.sessions.dragHandleAria': 'Drag {label} to reorder', + 'mobile.sessions.moveUpAria': 'Move {label} up', + 'mobile.sessions.moveDownAria': 'Move {label} down', + 'mobile.sessions.removeProjectAria': 'Remove {label}', + 'mobile.sessions.cancelRemoveProjectAria': 'Cancel removing {label}', + 'mobile.sessions.confirmRemoveProject': 'Delete', + 'mobile.sessions.confirmRemoveProjectAria': 'Confirm removing {label}', + 'mobile.sessions.toast.projectRemoved': 'Removed {label}', + 'mobile.sessions.showMore': 'Show {count} more', + 'mobile.sessions.search.section.sessions': 'Sessions', + 'mobile.sessions.search.section.archived': 'Archived', + 'mobile.sessions.search.section.projects': 'Projects', + 'mobile.sessions.clearSearchAria': 'Clear search', + 'mobile.header.noProject': 'Select a project', + 'mobile.header.activeSession': 'Active session', + 'mobile.header.noSession': 'No active session', + 'mobile.sessions.openSheetAria': 'Open sessions and projects', + 'mobile.sessions.closeSheetAria': 'Close sessions and projects', + 'mobile.sessions.sheet.title': 'Sessions', + 'mobile.sessions.sheet.description': 'Switch projects, open sessions, or start a new chat.', + 'mobile.sessions.search.placeholder': 'Search sessions', + 'mobile.sessions.empty': 'No sessions found.', + 'mobile.sessions.unassignedProject': 'Other sessions', + 'mobile.sessions.newSessionAria': 'Start a new session in this project', + 'mobile.sessions.untitled': 'Untitled session', + 'mobile.sessions.project.sessionsSingle': '1 session', + 'mobile.sessions.project.sessionsPlural': '{count} sessions', + 'mobile.files.refreshAria': 'Refresh files', + 'mobile.files.backToParentAria': 'Back to {name}', + 'mobile.files.rootDirectory': 'Project files', + 'mobile.files.search.placeholder': 'Search files', + 'mobile.files.search.empty': 'No files found.', + 'mobile.files.parentDirectory': 'Parent directory', + 'mobile.files.empty.noDirectory': 'Select a project to browse files.', + 'mobile.files.empty.directory': 'This directory is empty.', + 'mobile.files.error.listFailed': 'Failed to load files', + 'mobile.files.error.readUnavailable': 'File preview is unavailable in this runtime.', + 'mobile.files.file.truncated': 'File preview truncated for mobile.', + 'mobile.files.copyPathAria': 'Copy file path', + 'mobile.files.copyContent': 'Copy content', + 'mobile.files.copyContentAria': 'Copy file content', + 'mobile.files.toast.pathCopied': 'Path copied', + 'mobile.files.toast.contentCopied': 'Content copied', + 'mobile.files.toast.copyFailed': 'Copy failed', + 'mobile.changes.placeholder.title': 'Changes', + 'mobile.changes.placeholder.description': 'Working-tree review, sync, and commit actions will live here.', + 'mobile.changes.branchLabel': 'Branch: {branch}', + 'mobile.changes.noRemote': 'No remote available', + 'mobile.changes.cleanDescription': 'There are no changed files in this workspace.', + 'mobile.changes.diffDetail.subtitle': 'Read-only diff', + 'mobile.changes.diffDetail.loadFailed': 'Failed to load diff', + 'mobile.changes.diffDetail.missingTitle': 'File is no longer changed', + 'mobile.changes.diffDetail.missingDescription': 'Go back to Changes and refresh the list.', + 'mobile.changes.diffDetail.imageUnavailable': 'Image diffs are not available in mobile Changes yet.', + 'mobile.settings.placeholder.title': 'Settings', + 'mobile.settings.placeholder.description': 'Focused mobile connection and app settings will live here.', 'layout.rightSidebar.git': 'Git', 'layout.rightSidebar.files': 'Files', 'layout.rightSidebar.context': 'Context', @@ -1159,6 +1247,12 @@ export const dict = { 'header.services.refreshRateLimitsAria': 'Refresh rate limits', 'header.services.noRateLimits': 'No rate limits available.', 'header.services.noRateLimitsReported': 'No rate limits reported.', + 'header.services.remoteUpdate.title': 'Remote instance update', + 'header.services.remoteUpdate.checking': 'Looking for updates...', + 'header.services.remoteUpdate.upToDate': 'This instance is up to date.', + 'header.services.remoteUpdate.available': 'Version {version} is available for this instance.', + 'header.services.remoteUpdate.error': 'Failed to check remote instance updates', + 'header.services.remoteUpdate.actions.open': 'Update', 'header.services.used': 'Used', 'header.services.remaining': 'Remaining', 'header.services.modelFamily.other': 'Other', @@ -2069,6 +2163,9 @@ export const dict = { 'desktopHostSwitcher.header.currentDefaultColon': 'Current default:', 'desktopHostSwitcher.status.connected': 'Connected', 'desktopHostSwitcher.status.authRequired': 'Auth required', + 'desktopHostSwitcher.status.checking': 'Checking', + 'desktopHostSwitcher.status.updateRecommended': 'Update recommended', + 'desktopHostSwitcher.status.incompatible': 'Incompatible', 'desktopHostSwitcher.status.wrongService': 'Wrong service', 'desktopHostSwitcher.status.unreachable': 'Unreachable', 'desktopHostSwitcher.status.unknown': 'Unknown', @@ -2255,6 +2352,8 @@ export const dict = { 'onboarding.remoteConnection.actions.chooseDifferentServer': 'Choose Different Server', 'onboarding.remoteConnection.actions.useLocalInstead': 'Use Local Instead', 'onboarding.remoteConnection.probe.authMessage': 'Server requires authentication. You can still connect, but may need to provide credentials.', + 'onboarding.remoteConnection.probe.updateRecommendedMessage': 'This instance is running a different OpenChamber version. You can connect, but update both apps if something does not work.', + 'onboarding.remoteConnection.probe.incompatibleMessage': 'Server is running OpenChamber but is not compatible with this app version. Update OpenChamber on the server, then try again.', 'onboarding.remoteConnection.probe.wrongServiceMessage': 'Server responded but is not running OpenChamber. Verify the address points to an OpenChamber server.', 'onboarding.remoteConnection.probe.unreachableMessage': 'Server is unreachable. Check your network connection and verify the server address.', 'onboarding.desktopRecovery.localUnavailable.title': 'Local OpenCode Unavailable', @@ -2268,6 +2367,8 @@ export const dict = { 'onboarding.desktopRecovery.remoteUnreachable.retry': 'Retry Connection', 'onboarding.desktopRecovery.incompatibleServer.title': 'Incompatible Server', 'onboarding.desktopRecovery.incompatibleServer.description': 'The server at "{host}" is not running OpenChamber. Verify the address points to an OpenChamber server.', + 'onboarding.desktopRecovery.remoteIncompatible.title': 'Server Update Required', + 'onboarding.desktopRecovery.remoteIncompatible.description': 'The OpenChamber server at "{host}" is not compatible with this app version. Update OpenChamber on the server, then try again.', 'onboarding.desktopRecovery.common.useLocal': 'Use Local', 'onboarding.desktopRecovery.common.useRemote': 'Use Remote', 'onboarding.desktopRecovery.actions.retrying': 'Retrying…', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index f660dc73..aecbd29e 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -198,19 +198,50 @@ export const settingsDict = { "settings.magicPrompts.sidebar.item.sessionFusion": "Fusion", "settings.remoteInstances.sidebar.title": "Instancias remotas", "settings.remoteInstances.sidebar.total": "Total {count}", - "settings.remoteInstances.sidebar.newSshInstanceName": "Nueva instancia SSH", - "settings.remoteInstances.sidebar.actions.addSshInstance": "Añadir instancia SSH", + "settings.remoteInstances.sidebar.newSshInstanceName": "Nueva conexión SSH", + "settings.remoteInstances.sidebar.actions.addSshInstance": "Añadir conexión SSH", "settings.remoteInstances.sidebar.actions.connect": "Conectar", "settings.remoteInstances.sidebar.actions.disconnect": "Desconectar", "settings.remoteInstances.sidebar.actions.retry": "Reintentar", "settings.remoteInstances.sidebar.actions.remove": "Eliminar", "settings.remoteInstances.sidebar.confirm.localPortInUseRetry": "El puerto local ya está en uso. ¿Elegir un puerto libre aleatorio y reintentar?", - "settings.remoteInstances.sidebar.toast.createFailed": "No se pudo crear la instancia SSH", + "settings.remoteInstances.sidebar.toast.createFailed": "No se pudo crear la conexión SSH", "settings.remoteInstances.sidebar.toast.retriedWithRandomPort": "Reintentado con un puerto local aleatorio", "settings.remoteInstances.sidebar.toast.connectFailed": "No se pudo conectar la instancia", "settings.remoteInstances.sidebar.toast.disconnectFailed": "No se pudo desconectar la instancia", "settings.remoteInstances.sidebar.toast.retryFailed": "No se pudo reintentar la conexión", "settings.remoteInstances.sidebar.toast.removeFailed": "No se pudo eliminar la instancia", + "settings.remoteInstances.direct.sidebarTitle": "Enlaces a servidores", + "settings.remoteInstances.direct.sidebarDescription": "Conecta con un enlace o token", + "settings.remoteInstances.direct.title": "Otros servidores de OpenChamber", + "settings.remoteInstances.direct.description": "Añade otro servidor de OpenChamber por URL. Úsalo cuando el servidor ya esté en marcha y tengas un token de conexión.", + "settings.remoteInstances.direct.field.labelPlaceholder": "Etiqueta (opcional)", + "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", + "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexión (opcional para servidores locales de confianza)", + "settings.remoteInstances.direct.note": "Los tokens de conexión se guardan en este dispositivo y solo se usan cuando esta app se conecta a ese servidor.", + "settings.remoteInstances.direct.actions.add": "Añadir servidor", + "settings.remoteInstances.direct.import.description": "Pega un enlace de conexión de otro servidor de OpenChamber.", + "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", + "settings.remoteInstances.direct.import.action": "Importar enlace", + "settings.remoteInstances.direct.error.invalidConnectLink": "Enlace de conexión de OpenChamber no válido.", + "settings.remoteInstances.direct.state.loading": "Cargando servidores...", + "settings.remoteInstances.direct.state.empty": "Todavía no se han añadido otros servidores.", + "settings.remoteInstances.clientAuth.title": "Conectarse a este servidor", + "settings.remoteInstances.clientAuth.description": "Crea un enlace o token seguro para que OpenChamber Desktop pueda conectarse a este servidor.", + "settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nombre del dispositivo (opcional)", + "settings.remoteInstances.clientAuth.actions.create": "Crear token", + "settings.remoteInstances.clientAuth.actions.pair": "Crear enlace", + "settings.remoteInstances.clientAuth.actions.revoke": "Revocar", + "settings.remoteInstances.clientAuth.actions.clearRevoked": "Borrar revocados", + "settings.remoteInstances.clientAuth.qrAlt": "OpenChamber connection QR code", + "settings.remoteInstances.clientAuth.pairingUrl": "Enlace de conexión", + "settings.remoteInstances.clientAuth.createdToken": "Copia este token ahora. Por seguridad, no se volverá a mostrar.", + "settings.remoteInstances.clientAuth.state.loading": "Cargando tokens...", + "settings.remoteInstances.clientAuth.state.empty": "Todavía no hay dispositivos conectados.", + "settings.remoteInstances.clientAuth.state.revoked": "Revocado", + "settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo", + "settings.remoteInstances.clientAuth.lastUsed": "Último uso {date}", + "settings.remoteInstances.clientAuth.neverUsed": "Nunca usado", "settings.remoteInstances.sidebar.phase.ready": "Listo", "settings.remoteInstances.sidebar.phase.error": "Error", "settings.remoteInstances.sidebar.phase.reconnect": "Reconectar", @@ -221,20 +252,20 @@ export const settingsDict = { "settings.remoteInstances.sidebar.phase.connecting": "Conectando", "settings.remoteInstances.sidebar.phase.idle": "Inactivo", "settings.remoteInstances.page.section.instance": "Instancia", - "settings.remoteInstances.page.section.instanceDescription": "Configuración básica de SSH.", + "settings.remoteInstances.page.section.instanceDescription": "Choose the SSH command and a display name for this connection.", "settings.remoteInstances.page.field.mode": "Modo", - "settings.remoteInstances.page.field.modeHint": "Instalaciones/actualizaciones gestionadas y arranque remoto de OpenChamber. Externo asume que ya está en ejecución.", + "settings.remoteInstances.page.field.modeHint": "Elige si OpenChamber debe iniciar el servidor por ti o conectarse a uno que ya está en marcha.", "settings.remoteInstances.page.field.modePlaceholder": "Seleccionar modo", - "settings.remoteInstances.page.field.modeManaged": "Gestionado (arranque automático)", - "settings.remoteInstances.page.field.modeExternal": "Externo (ya en ejecución)", + "settings.remoteInstances.page.field.modeManaged": "Iniciarlo por mí", + "settings.remoteInstances.page.field.modeExternal": "Ya está en marcha", "settings.remoteInstances.page.field.preferredRemotePort": "Puerto remoto preferido", - "settings.remoteInstances.page.field.preferredRemotePortHint": "Puerto que debe usar OpenChamber en el host remoto. Dejar vacío para que el entorno lo elija.", + "settings.remoteInstances.page.field.preferredRemotePortHint": "Port to use on the remote machine. Leave empty to choose one automatically.", "settings.remoteInstances.page.field.keepServerRunning": "Mantener servidor en ejecución", - "settings.remoteInstances.page.field.keepServerRunningHint": "Si está habilitado, el demonio de OpenChamber permanece en ejecución remotamente al desconectar.", + "settings.remoteInstances.page.field.keepServerRunningHint": "Keep OpenChamber running on the remote machine after you disconnect.", "settings.remoteInstances.page.field.bindHost": "Host de enlace", - "settings.remoteInstances.page.field.bindHostHint": "Interfaz de red para la URL local principal. Usar 127.0.0.1/localhost para acceso local solamente.", + "settings.remoteInstances.page.field.bindHostHint": "Where the local connection should listen. Use 127.0.0.1 or localhost unless you need LAN access.", "settings.remoteInstances.page.field.preferredLocalPort": "Puerto local preferido", - "settings.remoteInstances.page.field.preferredLocalPortHint": "Puerto local preferido para el túnel de OpenChamber principal. Dejar vacío para selección automática.", + "settings.remoteInstances.page.field.preferredLocalPortHint": "Local port to open for this connection. Leave empty to choose one automatically.", "settings.remoteInstances.page.field.forwardType": "Tipo de redirección", "settings.remoteInstances.page.field.localHostPlaceholder": "127.0.0.1", "settings.remoteInstances.page.field.remoteHostPlaceholder": "127.0.0.1", @@ -243,7 +274,7 @@ export const settingsDict = { "settings.remoteInstances.page.preview.localSocks5": "(local SOCKS5)", "settings.remoteInstances.page.preview.local": "(local)", "settings.remoteInstances.page.preview.remote": "(remoto)", - "settings.remoteInstances.page.toast.openLocalEndpointFailed": "No se pudo abrir el endpoint local", + "settings.remoteInstances.page.toast.openLocalEndpointFailed": "No se pudo abrir la dirección local", "settings.remoteInstances.page.toast.localUrlCopied": "URL local copiada", "settings.remoteInstances.page.actions.copyLocalUrl": "Copiar URL local", "settings.remoteInstances.page.actions.open": "Abrir", @@ -945,26 +976,26 @@ export const settingsDict = { "settings.usage.pace.waitSeparator": " · Esperar ", "settings.usage.pace.predictionLabel": "Pred.: ", "settings.remoteInstances.page.title": "Instancia remota", - "settings.remoteInstances.page.description": "Configura la conexión SSH, el servidor remoto y la configuración de redirección.", + "settings.remoteInstances.page.description": "Conéctate a otra máquina por SSH y abre OpenChamber allí.", "settings.remoteInstances.page.empty.selectInstance": "Selecciona una instancia para ver y editar sus configuraciones.", "settings.remoteInstances.page.empty.noExtraForwards": "No hay redirecciones adicionales de puerto configuradas.", "settings.remoteInstances.page.section.actions": "Acciones", - "settings.remoteInstances.page.section.actionsDescription": "Conectar, reconectar, inspeccionar registros o eliminar esta instancia.", - "settings.remoteInstances.page.section.remoteServer": "Servidor remoto", - "settings.remoteInstances.page.section.remoteServerDescription": "Cómo OpenChamber se gestiona y inicia en el host remoto.", - "settings.remoteInstances.page.section.mainTunnel": "Túnel principal", - "settings.remoteInstances.page.section.mainTunnelDescription": "Punto de conexión local principal para esta instancia remota.", + "settings.remoteInstances.page.section.actionsDescription": "Conecta, reconecta, revisa registros o elimina esta conexión.", + "settings.remoteInstances.page.section.remoteServer": "OpenChamber en la máquina remota", + "settings.remoteInstances.page.section.remoteServerDescription": "Elige cómo debe ejecutarse OpenChamber después de conectar por SSH.", + "settings.remoteInstances.page.section.mainTunnel": "Acceso local", + "settings.remoteInstances.page.section.mainTunnelDescription": "Elige la dirección local que se usará para abrir este servidor remoto de OpenChamber.", "settings.remoteInstances.page.section.authentication": "Autenticación", "settings.remoteInstances.page.section.authenticationDescription": "Credenciales opcionales para SSH y la interfaz de usuario de OpenChamber remoto.", "settings.remoteInstances.page.section.portForwards": "Redirecciones de puerto", - "settings.remoteInstances.page.section.portForwardsDescription": "Redirecciones SSH adicionales más allá del túnel principal.", + "settings.remoteInstances.page.section.portForwardsDescription": "Puertos adicionales opcionales que estarán disponibles a través de esta conexión SSH.", "settings.remoteInstances.page.field.sshCommand": "Comando SSH", "settings.remoteInstances.page.field.sshCommandPlaceholder": "ssh user@host", "settings.remoteInstances.page.field.nickname": "Apodo", - "settings.remoteInstances.page.field.nicknamePlaceholder": "Mi host remoto", + "settings.remoteInstances.page.field.nicknamePlaceholder": "Portátil de trabajo", "settings.remoteInstances.page.field.connectionTimeoutSeconds": "Tiempo de espera de conexión (segundos)", "settings.remoteInstances.page.field.installMethod": "Método de instalación", - "settings.remoteInstances.page.field.installMethodHint": "Cómo se instala OpenChamber cuando se ejecuta en modo gestionado.", + "settings.remoteInstances.page.field.installMethodHint": "Cómo debe colocarse OpenChamber en la máquina remota cuando esta app lo inicia por ti.", "settings.remoteInstances.page.field.selectInstallMethodPlaceholder": "Seleccionar método de instalación", "settings.remoteInstances.page.field.installMethodDownloadRelease": "Descargar versión", "settings.remoteInstances.page.field.installMethodUploadBundle": "Subir paquete", @@ -973,14 +1004,14 @@ export const settingsDict = { "settings.remoteInstances.page.field.sshPasswordPlaceholder": "Introducir contraseña SSH", "settings.remoteInstances.page.field.uiPasswordOptional": "Contraseña de interfaz de usuario (opcional)", "settings.remoteInstances.page.field.uiPasswordPlaceholder": "Introducir contraseña de interfaz de usuario", - "settings.remoteInstances.page.field.forwardTypeHint": "Elige redirección local (-L), remota (-R) o dinámica (-D).", + "settings.remoteInstances.page.field.forwardTypeHint": "Elige qué tipo de acceso a puertos debe ofrecer esta conexión SSH.", "settings.remoteInstances.page.field.typePlaceholder": "Tipo", "settings.remoteInstances.page.forwardType.local": "Local (-L)", "settings.remoteInstances.page.forwardType.remote": "Remota (-R)", "settings.remoteInstances.page.forwardType.dynamic": "Dinámica (-D)", - "settings.remoteInstances.page.forwardTypeDescription.local": "Redirige el tráfico local a un destino remoto.", - "settings.remoteInstances.page.forwardTypeDescription.remote": "Expón un endpoint remoto y redirígelo de vuelta a su máquina local.", - "settings.remoteInstances.page.forwardTypeDescription.dynamic": "Expón un proxy SOCKS5 local sobre SSH.", + "settings.remoteInstances.page.forwardTypeDescription.local": "Abre un puerto local que se conecta a un servicio en la máquina remota.", + "settings.remoteInstances.page.forwardTypeDescription.remote": "Abre un puerto en la máquina remota que vuelve a conectarse a tu ordenador.", + "settings.remoteInstances.page.forwardTypeDescription.dynamic": "Abre un proxy SOCKS local a través de la conexión SSH.", "settings.remoteInstances.page.actions.create": "Crear", "settings.remoteInstances.page.actions.cancel": "Cancelar", "settings.remoteInstances.page.actions.connecting": "Conectando...", @@ -991,7 +1022,7 @@ export const settingsDict = { "settings.remoteInstances.page.actions.enableForwardAria": "Habilitar redirección", "settings.remoteInstances.page.actions.openLocal": "Abrir local", "settings.remoteInstances.page.actions.addForward": "Añadir redirección", - "settings.remoteInstances.page.import.sectionTitle": "Importar desde configuración SSH", + "settings.remoteInstances.page.import.sectionTitle": "Hosts SSH guardados", "settings.remoteInstances.page.import.loading": "Cargando hosts SSH...", "settings.remoteInstances.page.import.noneFound": "No se encontraron hosts SSH.", "settings.remoteInstances.page.import.noneAvailable": "No hay hosts SSH disponibles para importar.", @@ -1006,7 +1037,7 @@ export const settingsDict = { "settings.remoteInstances.page.phase.resolvingConfiguration": "Resolviendo configuración", "settings.remoteInstances.page.phase.checkingAuth": "Verificando autenticación", "settings.remoteInstances.page.phase.establishingSsh": "Estableciendo conexión SSH", - "settings.remoteInstances.page.phase.probingRemote": "Explorando host remoto", + "settings.remoteInstances.page.phase.probingRemote": "Comprobando la máquina remota", "settings.remoteInstances.page.phase.installingOpenChamber": "Instalando OpenChamber", "settings.remoteInstances.page.phase.updatingOpenChamber": "Actualizando OpenChamber", "settings.remoteInstances.page.phase.detectingServer": "Detectando servidor", @@ -1471,6 +1502,9 @@ export const settingsDict = { "settings.voice.page.preview.voiceLine": "¡Hola! Soy {voiceName}. Así suena mi voz.", "settings.voice.page.preview.customServerLine": "¡Hola! Esta es una previsualización del servidor TTS personalizado.", "settings.openchamber.visual.section.colorMode": "Modo de color", + "settings.openchamber.visual.section.mobileLayout": "Diseño móvil", + "settings.openchamber.visual.option.mobileLayout.default": "Predeterminado", + "settings.openchamber.visual.option.mobileLayout.new": "Nuevo", "settings.openchamber.visual.section.localization": "Localización", "settings.openchamber.visual.section.spacingAndLayout": "Espaciado y diseño", "settings.openchamber.visual.section.navigation": "Navegación", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index e12b3022..2606e916 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -27,6 +27,94 @@ export const dict: Record = { "layout.mainTab.files": "Archivos", "layout.mainTab.terminal": "Terminal", "layout.mainTab.context": "Contexto", + "mobile.nav.aria": "Navegación móvil", + "mobile.nav.changes": "Cambios", + "mobile.nav.settings": "Ajustes", + "mobile.surface.closeAria": "Cerrar", + "mobile.header.openMenuAria": "Abrir menú", + "mobile.menu.titleAria": "Herramientas del espacio de trabajo", + "mobile.menu.files": "Archivos", + "mobile.menu.changes": "Cambios", + "mobile.menu.settings": "Ajustes", + "mobile.sessions.newChatCta": "Nuevo chat en {project}", + "mobile.sessions.dateGroup.today": "Hoy", + "mobile.sessions.dateGroup.yesterday": "Ayer", + "mobile.sessions.dateGroup.thisWeek": "Antes esta semana", + "mobile.sessions.dateGroup.older": "Más antiguos", + "mobile.sessions.section.worktrees": "Worktrees", + "mobile.sessions.section.otherProjects": "Cambiar de proyecto", + "mobile.sessions.section.projects": "Proyectos", + "mobile.sessions.empty.noProjectsTitle": "Sin proyectos", + "mobile.sessions.empty.noProjectsDescription": "Agrega un proyecto para empezar a chatear con tu código.", + "mobile.sessions.empty.noSessionsTitle": "Sin sesiones", + "mobile.sessions.empty.noSessionsDescription": "Inicia tu primer chat para verlo aquí.", + "mobile.sessions.empty.searchTitle": "Sin resultados", + "mobile.sessions.empty.searchDescription": "Prueba con otro término de búsqueda.", + "mobile.sessions.showArchived": "Mostrar archivadas ({count})", + "mobile.sessions.hideArchived": "Ocultar archivadas", + "mobile.sessions.activeWorktreeAria": "Worktree activo", + "mobile.sessions.activeProjectAria": "Proyecto activo", + "mobile.sessions.startNewChat": "Iniciar nuevo chat", + "mobile.sessions.newChat": "Nuevo chat", + "mobile.sessions.editOrder": "Reordenar proyectos", + "mobile.sessions.doneEditing": "Listo", + "mobile.sessions.editOrderHint": "Arrastra el asa o usa las flechas para reordenar los proyectos. Toca la marca para finalizar.", + "mobile.sessions.dragHandleAria": "Arrastra {label} para reordenar", + "mobile.sessions.moveUpAria": "Mover {label} arriba", + "mobile.sessions.moveDownAria": "Mover {label} abajo", + "mobile.sessions.removeProjectAria": "Eliminar {label}", + "mobile.sessions.cancelRemoveProjectAria": "Cancelar eliminación de {label}", + "mobile.sessions.confirmRemoveProject": "Eliminar", + "mobile.sessions.confirmRemoveProjectAria": "Confirmar eliminación de {label}", + "mobile.sessions.toast.projectRemoved": "Se eliminó {label}", + "mobile.sessions.showMore": "Mostrar {count} más", + "mobile.sessions.search.section.sessions": "Sesiones", + "mobile.sessions.search.section.archived": "Archivadas", + "mobile.sessions.search.section.projects": "Proyectos", + "mobile.sessions.clearSearchAria": "Limpiar búsqueda", + "mobile.header.noProject": "Selecciona un proyecto", + "mobile.header.activeSession": "Sesión activa", + "mobile.header.noSession": "No hay sesión activa", + "mobile.sessions.openSheetAria": "Abrir sesiones y proyectos", + "mobile.sessions.closeSheetAria": "Cerrar sesiones y proyectos", + "mobile.sessions.sheet.title": "Sesiones", + "mobile.sessions.sheet.description": "Cambia proyectos, abre sesiones o inicia un nuevo chat.", + "mobile.sessions.search.placeholder": "Buscar sesiones", + "mobile.sessions.empty": "No se encontraron sesiones.", + "mobile.sessions.unassignedProject": "Otras sesiones", + "mobile.sessions.newSessionAria": "Iniciar una nueva sesión en este proyecto", + "mobile.sessions.untitled": "Sesión sin título", + "mobile.sessions.project.sessionsSingle": "1 sesión", + "mobile.sessions.project.sessionsPlural": "{count} sesiones", + "mobile.files.refreshAria": "Actualizar archivos", + "mobile.files.backToParentAria": "Volver a {name}", + "mobile.files.rootDirectory": "Archivos del proyecto", + "mobile.files.search.placeholder": "Buscar archivos", + "mobile.files.search.empty": "No se encontraron archivos.", + "mobile.files.parentDirectory": "Directorio superior", + "mobile.files.empty.noDirectory": "Selecciona un proyecto para explorar archivos.", + "mobile.files.empty.directory": "Este directorio está vacío.", + "mobile.files.error.listFailed": "No se pudieron cargar los archivos", + "mobile.files.error.readUnavailable": "La vista previa del archivo no está disponible en este entorno.", + "mobile.files.file.truncated": "Vista previa del archivo truncada para móvil.", + "mobile.files.copyPathAria": "Copiar ruta del archivo", + "mobile.files.copyContent": "Copiar contenido", + "mobile.files.copyContentAria": "Copiar contenido del archivo", + "mobile.files.toast.pathCopied": "Ruta copiada", + "mobile.files.toast.contentCopied": "Contenido copiado", + "mobile.files.toast.copyFailed": "No se pudo copiar", + "mobile.changes.placeholder.title": "Cambios", + "mobile.changes.placeholder.description": "Aquí vivirán la revisión del árbol de trabajo, sync y commits.", + "mobile.changes.branchLabel": "Rama: {branch}", + "mobile.changes.noRemote": "No hay remoto disponible", + "mobile.changes.cleanDescription": "No hay archivos modificados en este workspace.", + "mobile.changes.diffDetail.subtitle": "Diff de solo lectura", + "mobile.changes.diffDetail.loadFailed": "No se pudo cargar el diff", + "mobile.changes.diffDetail.missingTitle": "El archivo ya no tiene cambios", + "mobile.changes.diffDetail.missingDescription": "Vuelve a Cambios y actualiza la lista.", + "mobile.changes.diffDetail.imageUnavailable": "Los diffs de imagen aún no están disponibles en Cambios móvil.", + "mobile.settings.placeholder.title": "Ajustes", + "mobile.settings.placeholder.description": "Aquí vivirán los ajustes móviles de conexión y app.", "layout.rightSidebar.git": "Git", "layout.rightSidebar.files": "Archivos", "layout.rightSidebar.context": "Contexto", @@ -1125,6 +1213,12 @@ export const dict: Record = { "header.services.refreshRateLimitsAria": "Actualizar límites de tasa", "header.services.noRateLimits": "No hay límites de tasa disponibles.", "header.services.noRateLimitsReported": "No se reportaron límites de tasa.", + "header.services.remoteUpdate.title": "Actualización de instancia remota", + "header.services.remoteUpdate.checking": "Buscando actualizaciones...", + "header.services.remoteUpdate.upToDate": "Esta instancia está actualizada.", + "header.services.remoteUpdate.available": "La versión {version} está disponible para esta instancia.", + "header.services.remoteUpdate.error": "No se pudieron comprobar las actualizaciones de la instancia remota", + "header.services.remoteUpdate.actions.open": "Actualizar", "header.services.used": "Usado", "header.services.remaining": "Restante", "header.services.modelFamily.other": "Otro", @@ -2035,6 +2129,9 @@ export const dict: Record = { "desktopHostSwitcher.header.currentDefaultColon": "Predeterminado actual:", "desktopHostSwitcher.status.connected": "Conectado", "desktopHostSwitcher.status.authRequired": "Autenticación requerida", + "desktopHostSwitcher.status.checking": "Comprobando", + "desktopHostSwitcher.status.updateRecommended": "Actualización recomendada", + "desktopHostSwitcher.status.incompatible": "Incompatible", "desktopHostSwitcher.status.wrongService": "Servicio incorrecto", "desktopHostSwitcher.status.unreachable": "Inalcanzable", "desktopHostSwitcher.status.unknown": "Desconocido", @@ -2221,6 +2318,8 @@ export const dict: Record = { "onboarding.remoteConnection.actions.chooseDifferentServer": "Elegir otro servidor", "onboarding.remoteConnection.actions.useLocalInstead": "Usar Local en su lugar", "onboarding.remoteConnection.probe.authMessage": "El servidor requiere autenticación. Puedes conectarte aún así, pero puede que tengas que proporcionar credenciales.", + "onboarding.remoteConnection.probe.updateRecommendedMessage": "Esta instancia ejecuta una versión diferente de OpenChamber. Puedes conectarte, pero actualiza ambas apps si algo no funciona.", + "onboarding.remoteConnection.probe.incompatibleMessage": "El servidor ejecuta OpenChamber pero no es compatible con esta versión de la app. Actualiza OpenChamber en el servidor y vuelve a intentarlo.", "onboarding.remoteConnection.probe.wrongServiceMessage": "El servidor respondió pero no está ejecutando OpenChamber. Verifica que la dirección apunte a un servidor OpenChamber.", "onboarding.remoteConnection.probe.unreachableMessage": "El servidor no está disponible. Revisa tu conexión de red y verifica la dirección del servidor.", "onboarding.desktopRecovery.localUnavailable.title": "OpenCode Local no disponible", @@ -2234,6 +2333,8 @@ export const dict: Record = { "onboarding.desktopRecovery.remoteUnreachable.retry": "Reintentar conexión", "onboarding.desktopRecovery.incompatibleServer.title": "Servidor incompatible", "onboarding.desktopRecovery.incompatibleServer.description": "El servidor en \"{host}\" no está ejecutando OpenChamber. Verifica que la dirección apunte a un servidor OpenChamber.", + "onboarding.desktopRecovery.remoteIncompatible.title": "Actualización del servidor requerida", + "onboarding.desktopRecovery.remoteIncompatible.description": "El servidor OpenChamber en \"{host}\" no es compatible con esta versión de la app. Actualiza OpenChamber en el servidor y vuelve a intentarlo.", "onboarding.desktopRecovery.common.useLocal": "Usar Local", "onboarding.desktopRecovery.common.useRemote": "Usar remoto", "onboarding.desktopRecovery.actions.retrying": "Reintentando…", diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index eb0c5c1a..c9bdbd91 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -198,19 +198,50 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.sessionFusion': 'Fusion', 'settings.remoteInstances.sidebar.title': '원격 인스턴스', 'settings.remoteInstances.sidebar.total': '총 {count}개', - 'settings.remoteInstances.sidebar.newSshInstanceName': '새 SSH 인스턴스', - 'settings.remoteInstances.sidebar.actions.addSshInstance': 'SSH 인스턴스 추가', + 'settings.remoteInstances.sidebar.newSshInstanceName': '새 SSH 연결', + 'settings.remoteInstances.sidebar.actions.addSshInstance': 'SSH 연결 추가', 'settings.remoteInstances.sidebar.actions.connect': '연결', 'settings.remoteInstances.sidebar.actions.disconnect': '연결 해제', 'settings.remoteInstances.sidebar.actions.retry': '다시 시도', 'settings.remoteInstances.sidebar.actions.remove': '제거', 'settings.remoteInstances.sidebar.confirm.localPortInUseRetry': '로컬 포트가 이미 사용 중입니다. 임의의 사용 가능한 로컬 포트를 선택해 다시 시도할까요?', - 'settings.remoteInstances.sidebar.toast.createFailed': 'SSH 인스턴스를 생성하지 못했습니다', + 'settings.remoteInstances.sidebar.toast.createFailed': 'SSH 연결을 만들지 못했습니다', 'settings.remoteInstances.sidebar.toast.retriedWithRandomPort': '임의의 로컬 포트로 다시 시도했습니다', 'settings.remoteInstances.sidebar.toast.connectFailed': '인스턴스에 연결하지 못했습니다', 'settings.remoteInstances.sidebar.toast.disconnectFailed': '인스턴스 연결을 해제하지 못했습니다', 'settings.remoteInstances.sidebar.toast.retryFailed': '연결을 다시 시도하지 못했습니다', 'settings.remoteInstances.sidebar.toast.removeFailed': '인스턴스를 제거하지 못했습니다', + 'settings.remoteInstances.direct.sidebarTitle': '서버 링크', + 'settings.remoteInstances.direct.sidebarDescription': '링크나 토큰으로 연결', + 'settings.remoteInstances.direct.title': '다른 OpenChamber 서버', + 'settings.remoteInstances.direct.description': 'URL로 다른 OpenChamber 서버를 추가합니다. 서버가 이미 실행 중이고 연결 토큰이 있을 때 사용하세요.', + 'settings.remoteInstances.direct.field.labelPlaceholder': '라벨(선택 사항)', + 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', + 'settings.remoteInstances.direct.field.tokenPlaceholder': '연결 토큰(신뢰할 수 있는 로컬 서버는 선택 사항)', + 'settings.remoteInstances.direct.note': '연결 토큰은 이 기기에 저장되며 이 앱이 해당 서버에 연결할 때만 사용됩니다.', + 'settings.remoteInstances.direct.actions.add': '서버 추가', + 'settings.remoteInstances.direct.import.description': '다른 OpenChamber 서버에서 만든 연결 링크를 붙여넣으세요.', + 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', + 'settings.remoteInstances.direct.import.action': '링크 가져오기', + 'settings.remoteInstances.direct.error.invalidConnectLink': '잘못된 OpenChamber 연결 링크입니다.', + 'settings.remoteInstances.direct.state.loading': '서버를 불러오는 중...', + 'settings.remoteInstances.direct.state.empty': '아직 추가된 다른 서버가 없습니다.', + 'settings.remoteInstances.clientAuth.title': '이 서버에 연결', + 'settings.remoteInstances.clientAuth.description': 'OpenChamber Desktop이 이 서버에 연결할 수 있도록 안전한 링크나 토큰을 만듭니다.', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': '기기 이름(선택 사항)', + 'settings.remoteInstances.clientAuth.actions.create': '토큰 만들기', + 'settings.remoteInstances.clientAuth.actions.pair': '링크 만들기', + 'settings.remoteInstances.clientAuth.actions.revoke': '해지', + 'settings.remoteInstances.clientAuth.actions.clearRevoked': '해지된 항목 지우기', + 'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code', + 'settings.remoteInstances.clientAuth.pairingUrl': '연결 링크', + 'settings.remoteInstances.clientAuth.createdToken': '지금 이 토큰을 복사하세요. 보안을 위해 다시 표시되지 않습니다.', + 'settings.remoteInstances.clientAuth.state.loading': '토큰을 불러오는 중...', + 'settings.remoteInstances.clientAuth.state.empty': '아직 연결된 기기가 없습니다.', + 'settings.remoteInstances.clientAuth.state.revoked': '해지됨', + 'settings.remoteInstances.clientAuth.state.thisDevice': '이 기기', + 'settings.remoteInstances.clientAuth.lastUsed': '마지막 사용 {date}', + 'settings.remoteInstances.clientAuth.neverUsed': '사용한 적 없음', 'settings.remoteInstances.sidebar.phase.ready': '준비됨', 'settings.remoteInstances.sidebar.phase.error': '오류', 'settings.remoteInstances.sidebar.phase.reconnect': '재연결', @@ -221,20 +252,20 @@ export const settingsDict = { 'settings.remoteInstances.sidebar.phase.connecting': '연결 중', 'settings.remoteInstances.sidebar.phase.idle': '대기 중', 'settings.remoteInstances.page.section.instance': '인스턴스', - 'settings.remoteInstances.page.section.instanceDescription': '핵심 SSH 설정입니다.', + 'settings.remoteInstances.page.section.instanceDescription': '이 연결에 사용할 SSH 명령과 표시 이름을 선택하세요.', 'settings.remoteInstances.page.field.mode': '모드', - 'settings.remoteInstances.page.field.modeHint': 'Managed는 원격에서 OpenChamber를 설치·업데이트하고 시작합니다. External은 이미 실행 중인 서버에 연결합니다.', + 'settings.remoteInstances.page.field.modeHint': 'OpenChamber가 서버를 대신 시작할지, 이미 실행 중인 서버에 연결할지 선택하세요.', 'settings.remoteInstances.page.field.modePlaceholder': '모드 선택', - 'settings.remoteInstances.page.field.modeManaged': 'Managed(자동 시작)', - 'settings.remoteInstances.page.field.modeExternal': 'External(이미 실행 중)', + 'settings.remoteInstances.page.field.modeManaged': '대신 시작하기', + 'settings.remoteInstances.page.field.modeExternal': '이미 실행 중', 'settings.remoteInstances.page.field.preferredRemotePort': '기본 원격 포트', - 'settings.remoteInstances.page.field.preferredRemotePortHint': '원격 host에서 OpenChamber가 사용할 포트입니다. 비워 두면 런타임이 자동으로 선택합니다.', + 'settings.remoteInstances.page.field.preferredRemotePortHint': '원격 컴퓨터에서 사용할 포트입니다. 비워 두면 자동으로 선택합니다.', 'settings.remoteInstances.page.field.keepServerRunning': '서버 유지', - 'settings.remoteInstances.page.field.keepServerRunningHint': '활성화하면 연결 해제 후에도 OpenChamber daemon이 원격에서 계속 실행됩니다.', + 'settings.remoteInstances.page.field.keepServerRunningHint': '연결을 끊은 뒤에도 원격 컴퓨터에서 OpenChamber를 계속 실행합니다.', 'settings.remoteInstances.page.field.bindHost': 'Bind host', - 'settings.remoteInstances.page.field.bindHostHint': '기본 로컬 URL의 네트워크 인터페이스입니다. 로컬 전용 접속에는 127.0.0.1/localhost를 사용하세요.', + 'settings.remoteInstances.page.field.bindHostHint': '로컬 연결이 대기할 주소입니다. LAN 접근이 필요하지 않으면 127.0.0.1 또는 localhost를 사용하세요.', 'settings.remoteInstances.page.field.preferredLocalPort': '기본 로컬 포트', - 'settings.remoteInstances.page.field.preferredLocalPortHint': '기본 OpenChamber 터널에 사용할 로컬 포트입니다. 자동 선택하려면 비워 두세요.', + 'settings.remoteInstances.page.field.preferredLocalPortHint': '이 연결에 열 로컬 포트입니다. 비워 두면 자동으로 선택합니다.', 'settings.remoteInstances.page.field.forwardType': '포워딩 유형', 'settings.remoteInstances.page.field.localHostPlaceholder': '127.0.0.1', 'settings.remoteInstances.page.field.remoteHostPlaceholder': '127.0.0.1', @@ -243,7 +274,7 @@ export const settingsDict = { 'settings.remoteInstances.page.preview.localSocks5': '(로컬 SOCKS5)', 'settings.remoteInstances.page.preview.local': '(로컬)', 'settings.remoteInstances.page.preview.remote': '(원격)', - 'settings.remoteInstances.page.toast.openLocalEndpointFailed': '로컬 엔드포인트를 열지 못했습니다', + 'settings.remoteInstances.page.toast.openLocalEndpointFailed': '로컬 주소를 열지 못했습니다', 'settings.remoteInstances.page.toast.localUrlCopied': '로컬 URL이 복사되었습니다', 'settings.remoteInstances.page.actions.copyLocalUrl': '로컬 URL 복사', 'settings.remoteInstances.page.actions.open': '열기', @@ -945,26 +976,26 @@ export const settingsDict = { 'settings.usage.pace.waitSeparator': ' · 대기 ', 'settings.usage.pace.predictionLabel': '예측: ', 'settings.remoteInstances.page.title': '원격 인스턴스', - 'settings.remoteInstances.page.description': 'SSH 연결, 원격 서버, 포워딩 설정을 관리하세요.', + 'settings.remoteInstances.page.description': 'SSH로 다른 컴퓨터에 연결하고 그곳에서 OpenChamber를 엽니다.', 'settings.remoteInstances.page.empty.selectInstance': '설정을 보고 편집할 인스턴스를 선택하세요.', 'settings.remoteInstances.page.empty.noExtraForwards': '추가 포트 포워딩이 설정되어 있지 않습니다.', 'settings.remoteInstances.page.section.actions': '작업', - 'settings.remoteInstances.page.section.actionsDescription': '이 인스턴스를 연결, 재연결, 로그 확인 또는 제거합니다.', - 'settings.remoteInstances.page.section.remoteServer': '원격 서버', - 'settings.remoteInstances.page.section.remoteServerDescription': '원격 host에서 OpenChamber를 관리하고 시작하는 방식입니다.', - 'settings.remoteInstances.page.section.mainTunnel': '기본 터널', - 'settings.remoteInstances.page.section.mainTunnelDescription': '이 원격 인스턴스의 기본 로컬 엔드포인트입니다.', + 'settings.remoteInstances.page.section.actionsDescription': '연결, 재연결, 로그 보기 또는 이 연결 삭제를 할 수 있습니다.', + 'settings.remoteInstances.page.section.remoteServer': '원격 컴퓨터의 OpenChamber', + 'settings.remoteInstances.page.section.remoteServerDescription': 'SSH 연결 후 OpenChamber를 어떻게 실행할지 선택하세요.', + 'settings.remoteInstances.page.section.mainTunnel': '로컬 접근', + 'settings.remoteInstances.page.section.mainTunnelDescription': '이 원격 OpenChamber 서버를 열 때 사용할 로컬 주소를 선택하세요.', 'settings.remoteInstances.page.section.authentication': '인증', 'settings.remoteInstances.page.section.authenticationDescription': 'SSH와 원격 OpenChamber UI를 위한 인증 정보입니다.', 'settings.remoteInstances.page.section.portForwards': '포트 포워딩', - 'settings.remoteInstances.page.section.portForwardsDescription': '기본 터널 외 추가 SSH 포워딩입니다.', + 'settings.remoteInstances.page.section.portForwardsDescription': '이 SSH 연결을 통해 추가로 사용할 포트를 설정합니다.', 'settings.remoteInstances.page.field.sshCommand': 'SSH 명령어', 'settings.remoteInstances.page.field.sshCommandPlaceholder': 'ssh user@host', 'settings.remoteInstances.page.field.nickname': '별칭', - 'settings.remoteInstances.page.field.nicknamePlaceholder': '내 원격 host', + 'settings.remoteInstances.page.field.nicknamePlaceholder': '업무용 노트북', 'settings.remoteInstances.page.field.connectionTimeoutSeconds': '연결 시간 초과(초)', 'settings.remoteInstances.page.field.installMethod': '설치 방식', - 'settings.remoteInstances.page.field.installMethodHint': 'Managed 모드에서 OpenChamber를 설치하는 방식입니다.', + 'settings.remoteInstances.page.field.installMethodHint': '이 앱이 대신 시작할 때 OpenChamber를 원격 컴퓨터에 배치하는 방법입니다.', 'settings.remoteInstances.page.field.selectInstallMethodPlaceholder': '설치 방식 선택', 'settings.remoteInstances.page.field.installMethodDownloadRelease': '릴리스 다운로드', 'settings.remoteInstances.page.field.installMethodUploadBundle': '번들 업로드', @@ -973,14 +1004,14 @@ export const settingsDict = { 'settings.remoteInstances.page.field.sshPasswordPlaceholder': 'SSH 비밀번호 입력', 'settings.remoteInstances.page.field.uiPasswordOptional': 'UI 비밀번호(선택 사항)', 'settings.remoteInstances.page.field.uiPasswordPlaceholder': 'UI 비밀번호 입력', - 'settings.remoteInstances.page.field.forwardTypeHint': '로컬(-L), 원격(-R), 동적(-D) 포워딩 중 선택하세요.', + 'settings.remoteInstances.page.field.forwardTypeHint': '이 SSH 연결이 제공할 포트 접근 방식을 선택하세요.', 'settings.remoteInstances.page.field.typePlaceholder': '유형', 'settings.remoteInstances.page.forwardType.local': '로컬(-L)', 'settings.remoteInstances.page.forwardType.remote': '원격(-R)', 'settings.remoteInstances.page.forwardType.dynamic': '동적(-D)', - 'settings.remoteInstances.page.forwardTypeDescription.local': '로컬 트래픽을 원격 대상으로 포워딩합니다.', - 'settings.remoteInstances.page.forwardTypeDescription.remote': '원격 엔드포인트를 노출하고 로컬 머신으로 다시 포워딩합니다.', - 'settings.remoteInstances.page.forwardTypeDescription.dynamic': '로컬 SOCKS5 proxy를 SSH로 노출합니다.', + 'settings.remoteInstances.page.forwardTypeDescription.local': '원격 컴퓨터의 서비스에 연결되는 로컬 포트를 엽니다.', + 'settings.remoteInstances.page.forwardTypeDescription.remote': '내 컴퓨터로 다시 연결되는 포트를 원격 컴퓨터에 엽니다.', + 'settings.remoteInstances.page.forwardTypeDescription.dynamic': 'SSH 연결을 통해 로컬 SOCKS 프록시를 엽니다.', 'settings.remoteInstances.page.actions.create': '생성', 'settings.remoteInstances.page.actions.cancel': '취소', 'settings.remoteInstances.page.actions.connecting': '연결 중...', @@ -991,7 +1022,7 @@ export const settingsDict = { 'settings.remoteInstances.page.actions.enableForwardAria': '포워딩 활성화', 'settings.remoteInstances.page.actions.openLocal': '로컬 열기', 'settings.remoteInstances.page.actions.addForward': '포워딩 추가', - 'settings.remoteInstances.page.import.sectionTitle': 'SSH config에서 가져오기', + 'settings.remoteInstances.page.import.sectionTitle': '저장된 SSH 호스트', 'settings.remoteInstances.page.import.loading': 'SSH host 로딩 중...', 'settings.remoteInstances.page.import.noneFound': 'SSH host를 찾을 수 없습니다.', 'settings.remoteInstances.page.import.noneAvailable': '가져올 수 있는 SSH host가 없습니다.', @@ -1006,7 +1037,7 @@ export const settingsDict = { 'settings.remoteInstances.page.phase.resolvingConfiguration': '설정 확인 중', 'settings.remoteInstances.page.phase.checkingAuth': '인증 확인 중', 'settings.remoteInstances.page.phase.establishingSsh': 'SSH 연결 설정 중', - 'settings.remoteInstances.page.phase.probingRemote': '원격 host 확인 중', + 'settings.remoteInstances.page.phase.probingRemote': '원격 컴퓨터 확인 중', 'settings.remoteInstances.page.phase.installingOpenChamber': 'OpenChamber 설치 중', 'settings.remoteInstances.page.phase.updatingOpenChamber': 'OpenChamber 업데이트 중', 'settings.remoteInstances.page.phase.detectingServer': '서버 감지 중', @@ -1471,6 +1502,9 @@ export const settingsDict = { 'settings.voice.page.preview.voiceLine': '안녕하세요! 저는 {voiceName}입니다. 이렇게 들립니다.', 'settings.voice.page.preview.customServerLine': '안녕하세요! 사용자 정의 TTS 서버 미리보기입니다.', 'settings.openchamber.visual.section.colorMode': '색상 모드', + 'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃', + 'settings.openchamber.visual.option.mobileLayout.default': '기본값', + 'settings.openchamber.visual.option.mobileLayout.new': '새로움', 'settings.openchamber.visual.section.localization': '지역화', 'settings.openchamber.visual.section.spacingAndLayout': '간격 및 레이아웃', 'settings.openchamber.visual.section.navigation': '내비게이션', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 8fe44ca9..1a858a81 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -27,6 +27,94 @@ export const dict: Record = { 'layout.mainTab.files': '파일', 'layout.mainTab.terminal': '터미널', 'layout.mainTab.context': '컨텍스트', + 'mobile.nav.aria': '모바일 내비게이션', + 'mobile.nav.changes': '변경사항', + 'mobile.nav.settings': '설정', + 'mobile.surface.closeAria': '닫기', + 'mobile.header.openMenuAria': '메뉴 열기', + 'mobile.menu.titleAria': '작업 공간 도구', + 'mobile.menu.files': '파일', + 'mobile.menu.changes': '변경사항', + 'mobile.menu.settings': '설정', + 'mobile.sessions.newChatCta': '{project}에서 새 채팅', + 'mobile.sessions.dateGroup.today': '오늘', + 'mobile.sessions.dateGroup.yesterday': '어제', + 'mobile.sessions.dateGroup.thisWeek': '이번 주 초', + 'mobile.sessions.dateGroup.older': '이전', + 'mobile.sessions.section.worktrees': '워크트리', + 'mobile.sessions.section.otherProjects': '프로젝트 전환', + 'mobile.sessions.section.projects': '프로젝트', + 'mobile.sessions.empty.noProjectsTitle': '프로젝트 없음', + 'mobile.sessions.empty.noProjectsDescription': '코드와 채팅을 시작하려면 프로젝트를 추가하세요.', + 'mobile.sessions.empty.noSessionsTitle': '세션 없음', + 'mobile.sessions.empty.noSessionsDescription': '여기에 표시하려면 첫 번째 채팅을 시작하세요.', + 'mobile.sessions.empty.searchTitle': '결과 없음', + 'mobile.sessions.empty.searchDescription': '다른 검색어를 시도해 보세요.', + 'mobile.sessions.showArchived': '보관된 항목 표시 ({count})', + 'mobile.sessions.hideArchived': '보관된 항목 숨기기', + 'mobile.sessions.activeWorktreeAria': '활성 워크트리', + 'mobile.sessions.activeProjectAria': '활성 프로젝트', + 'mobile.sessions.startNewChat': '새 채팅 시작', + 'mobile.sessions.newChat': '새 채팅', + 'mobile.sessions.editOrder': '프로젝트 순서 변경', + 'mobile.sessions.doneEditing': '완료', + 'mobile.sessions.editOrderHint': '핸들을 드래그하거나 화살표로 프로젝트 순서를 변경하세요. 완료하려면 체크를 누르세요.', + 'mobile.sessions.dragHandleAria': '{label} 드래그하여 순서 변경', + 'mobile.sessions.moveUpAria': '{label} 위로 이동', + 'mobile.sessions.moveDownAria': '{label} 아래로 이동', + 'mobile.sessions.removeProjectAria': '{label} 제거', + 'mobile.sessions.cancelRemoveProjectAria': '{label} 제거 취소', + 'mobile.sessions.confirmRemoveProject': '삭제', + 'mobile.sessions.confirmRemoveProjectAria': '{label} 제거 확인', + 'mobile.sessions.toast.projectRemoved': '{label} 제거됨', + 'mobile.sessions.showMore': '{count}개 더 보기', + 'mobile.sessions.search.section.sessions': '세션', + 'mobile.sessions.search.section.archived': '보관됨', + 'mobile.sessions.search.section.projects': '프로젝트', + 'mobile.sessions.clearSearchAria': '검색 지우기', + 'mobile.header.noProject': '프로젝트 선택', + 'mobile.header.activeSession': '활성 세션', + 'mobile.header.noSession': '활성 세션 없음', + 'mobile.sessions.openSheetAria': '세션 및 프로젝트 열기', + 'mobile.sessions.closeSheetAria': '세션 및 프로젝트 닫기', + 'mobile.sessions.sheet.title': '세션', + 'mobile.sessions.sheet.description': '프로젝트를 전환하고 세션을 열거나 새 채팅을 시작하세요.', + 'mobile.sessions.search.placeholder': '세션 검색', + 'mobile.sessions.empty': '세션을 찾을 수 없습니다.', + 'mobile.sessions.unassignedProject': '기타 세션', + 'mobile.sessions.newSessionAria': '이 프로젝트에서 새 세션 시작', + 'mobile.sessions.untitled': '제목 없는 세션', + 'mobile.sessions.project.sessionsSingle': '세션 1개', + 'mobile.sessions.project.sessionsPlural': '세션 {count}개', + 'mobile.files.refreshAria': '파일 새로고침', + 'mobile.files.backToParentAria': '{name}(으)로 돌아가기', + 'mobile.files.rootDirectory': '프로젝트 파일', + 'mobile.files.search.placeholder': '파일 검색', + 'mobile.files.search.empty': '파일을 찾을 수 없습니다.', + 'mobile.files.parentDirectory': '상위 디렉터리', + 'mobile.files.empty.noDirectory': '파일을 탐색할 프로젝트를 선택하세요.', + 'mobile.files.empty.directory': '이 디렉터리는 비어 있습니다.', + 'mobile.files.error.listFailed': '파일을 불러오지 못했습니다', + 'mobile.files.error.readUnavailable': '이 런타임에서는 파일 미리보기를 사용할 수 없습니다.', + 'mobile.files.file.truncated': '모바일용 파일 미리보기가 잘렸습니다.', + 'mobile.files.copyPathAria': '파일 경로 복사', + 'mobile.files.copyContent': '내용 복사', + 'mobile.files.copyContentAria': '파일 내용 복사', + 'mobile.files.toast.pathCopied': '경로가 복사되었습니다', + 'mobile.files.toast.contentCopied': '내용이 복사되었습니다', + 'mobile.files.toast.copyFailed': '복사하지 못했습니다', + 'mobile.changes.placeholder.title': '변경사항', + 'mobile.changes.placeholder.description': '작업 트리 검토, sync, commit 작업이 여기에 표시됩니다.', + 'mobile.changes.branchLabel': '브랜치: {branch}', + 'mobile.changes.noRemote': '사용 가능한 remote가 없습니다', + 'mobile.changes.cleanDescription': '이 workspace에는 변경된 파일이 없습니다.', + 'mobile.changes.diffDetail.subtitle': '읽기 전용 diff', + 'mobile.changes.diffDetail.loadFailed': 'diff를 불러오지 못했습니다', + 'mobile.changes.diffDetail.missingTitle': '파일이 더 이상 변경되지 않았습니다', + 'mobile.changes.diffDetail.missingDescription': '변경사항으로 돌아가 목록을 새로고침하세요.', + 'mobile.changes.diffDetail.imageUnavailable': '이미지 diff는 아직 모바일 변경사항에서 사용할 수 없습니다.', + 'mobile.settings.placeholder.title': '설정', + 'mobile.settings.placeholder.description': '모바일 연결 및 앱 설정이 여기에 표시됩니다.', 'layout.rightSidebar.git': 'Git', 'layout.rightSidebar.files': '파일', 'layout.rightSidebar.context': '컨텍스트', @@ -1162,6 +1250,12 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': '레이트 리밋 새로고침', 'header.services.noRateLimits': '사용 가능한 레이트 리밋이 없습니다.', 'header.services.noRateLimitsReported': '보고된 레이트 리밋이 없습니다.', + 'header.services.remoteUpdate.title': '원격 인스턴스 업데이트', + 'header.services.remoteUpdate.checking': '업데이트를 확인하는 중...', + 'header.services.remoteUpdate.upToDate': '이 인스턴스는 최신 상태입니다.', + 'header.services.remoteUpdate.available': '이 인스턴스에 버전 {version} 업데이트가 있습니다.', + 'header.services.remoteUpdate.error': '원격 인스턴스 업데이트를 확인하지 못했습니다', + 'header.services.remoteUpdate.actions.open': '업데이트', 'header.services.used': '사용됨', 'header.services.remaining': '남은 양', 'header.services.modelFamily.other': '기타', @@ -2069,6 +2163,9 @@ export const dict: Record = { 'desktopHostSwitcher.header.currentDefaultColon': '현재 기본값:', 'desktopHostSwitcher.status.connected': '연결됨', 'desktopHostSwitcher.status.authRequired': '인증 필요', + 'desktopHostSwitcher.status.checking': '확인 중', + 'desktopHostSwitcher.status.updateRecommended': '업데이트 권장', + 'desktopHostSwitcher.status.incompatible': '호환되지 않음', 'desktopHostSwitcher.status.wrongService': '잘못된 서비스', 'desktopHostSwitcher.status.unreachable': '연결할 수 없음', 'desktopHostSwitcher.status.unknown': '알 수 없음', @@ -2255,6 +2352,8 @@ export const dict: Record = { 'onboarding.remoteConnection.actions.chooseDifferentServer': '다른 서버 선택', 'onboarding.remoteConnection.actions.useLocalInstead': '대신 로컬 사용', 'onboarding.remoteConnection.probe.authMessage': '서버에 인증이 필요합니다. 연결은 가능하지만 자격 증명을 입력해야 할 수 있습니다.', + 'onboarding.remoteConnection.probe.updateRecommendedMessage': '이 인스턴스는 다른 OpenChamber 버전을 실행 중입니다. 연결할 수 있지만 문제가 있으면 양쪽 앱을 업데이트하세요.', + 'onboarding.remoteConnection.probe.incompatibleMessage': '서버에서 OpenChamber가 실행 중이지만 이 앱 버전과 호환되지 않습니다. 서버의 OpenChamber를 업데이트한 후 다시 시도하세요.', 'onboarding.remoteConnection.probe.wrongServiceMessage': '서버가 응답했지만 OpenChamber가 실행 중이 아닙니다. 주소가 OpenChamber 서버를 가리키는지 확인하세요.', 'onboarding.remoteConnection.probe.unreachableMessage': '서버에 연결할 수 없습니다. 네트워크 연결과 서버 주소를 확인하세요.', 'onboarding.desktopRecovery.localUnavailable.title': '로컬 OpenCode를 사용할 수 없음', @@ -2268,6 +2367,8 @@ export const dict: Record = { 'onboarding.desktopRecovery.remoteUnreachable.retry': '연결 다시 시도', 'onboarding.desktopRecovery.incompatibleServer.title': '호환되지 않는 서버', 'onboarding.desktopRecovery.incompatibleServer.description': '"{host}"의 서버에서 OpenChamber가 실행 중이 아닙니다. 주소가 OpenChamber 서버를 가리키는지 확인하세요.', + 'onboarding.desktopRecovery.remoteIncompatible.title': '서버 업데이트 필요', + 'onboarding.desktopRecovery.remoteIncompatible.description': '"{host}"의 OpenChamber 서버가 이 앱 버전과 호환되지 않습니다. 서버의 OpenChamber를 업데이트한 후 다시 시도하세요.', 'onboarding.desktopRecovery.common.useLocal': '로컬 사용', 'onboarding.desktopRecovery.common.useRemote': '원격 사용', 'onboarding.desktopRecovery.actions.retrying': '다시 시도 중…', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index eb79ff34..d91f6f80 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1007,6 +1007,9 @@ export const settingsDict = { 'settings.openchamber.visual.section.chatRenderMode': 'Tryb renderowania czatu', 'settings.openchamber.visual.section.chatRenderModeAria': 'Tryb renderowania czatu', 'settings.openchamber.visual.section.colorMode': 'Tryb kolorów', + 'settings.openchamber.visual.section.mobileLayout': 'Układ mobilny', + 'settings.openchamber.visual.option.mobileLayout.default': 'Domyślny', + 'settings.openchamber.visual.option.mobileLayout.new': 'Nowy', 'settings.openchamber.visual.section.diffLayout': 'Układ diffa', 'settings.openchamber.visual.section.diffLayoutAria': 'Układ diffa', 'settings.openchamber.visual.section.diffViewMode': 'Tryb widoku diffa', @@ -1257,33 +1260,33 @@ export const settingsDict = { 'settings.remoteInstances.page.confirm.removeInstance': 'Usunąć tę zdalną instancję?', 'settings.remoteInstances.page.confirm.storeSshPasswordPlaintext': 'Zapisać hasło SSH jawnym tekstem na dysku?', 'settings.remoteInstances.page.confirm.storeUiPasswordPlaintext': 'Zapisać hasło UI jawnym tekstem na dysku?', - 'settings.remoteInstances.page.description': 'Skonfiguruj połączenie SSH, zdalny serwer i ustawienia przekierowania.', + 'settings.remoteInstances.page.description': 'Połącz się z inną maszyną przez SSH i otwórz tam OpenChamber.', 'settings.remoteInstances.page.empty.noExtraForwards': 'Nie skonfigurowano dodatkowych przekierowań portów.', 'settings.remoteInstances.page.empty.selectInstance': 'Wybierz instancję, aby wyświetlić i edytować jej ustawienia.', 'settings.remoteInstances.page.field.auto': 'Auto', 'settings.remoteInstances.page.field.bindHost': 'Host powiązania', - 'settings.remoteInstances.page.field.bindHostHint': 'Interfejs sieciowy dla głównego lokalnego URL-a. Użyj 127.0.0.1/localhost dla dostępu tylko lokalnego.', + 'settings.remoteInstances.page.field.bindHostHint': 'Miejsce nasłuchiwania lokalnego połączenia. Użyj 127.0.0.1 lub localhost, chyba że potrzebujesz dostępu z sieci lokalnej.', 'settings.remoteInstances.page.field.connectionTimeoutSeconds': 'Limit czasu połączenia (sekundy)', 'settings.remoteInstances.page.field.forwardType': 'Typ przekierowania', - 'settings.remoteInstances.page.field.forwardTypeHint': 'Wybierz przekierowanie lokalne (-L), zdalne (-R) lub dynamiczne (-D).', + 'settings.remoteInstances.page.field.forwardTypeHint': 'Wybierz, jaki dostęp do portów ma zapewniać to połączenie SSH.', 'settings.remoteInstances.page.field.installMethod': 'Metoda instalacji', 'settings.remoteInstances.page.field.installMethodDownloadRelease': 'Pobierz wydanie', - 'settings.remoteInstances.page.field.installMethodHint': 'Jak OpenChamber jest instalowany podczas działania w trybie zarządzanym.', + 'settings.remoteInstances.page.field.installMethodHint': 'Jak OpenChamber ma zostać umieszczony na zdalnej maszynie, gdy aplikacja uruchamia go za Ciebie.', 'settings.remoteInstances.page.field.installMethodUploadBundle': 'Prześlij paczkę', 'settings.remoteInstances.page.field.keepServerRunning': 'Pozostaw serwer uruchomiony', - 'settings.remoteInstances.page.field.keepServerRunningHint': 'Jeśli opcja jest włączona, demon OpenChamber pozostaje uruchomiony zdalnie po rozłączeniu.', + 'settings.remoteInstances.page.field.keepServerRunningHint': 'Pozostaw OpenChamber uruchomiony na zdalnej maszynie po rozłączeniu.', 'settings.remoteInstances.page.field.localHostPlaceholder': '127.0.0.1', 'settings.remoteInstances.page.field.mode': 'Tryb', - 'settings.remoteInstances.page.field.modeExternal': 'Zewnętrzny (już uruchomiony)', - 'settings.remoteInstances.page.field.modeHint': 'Tryb zarządzany instaluje lub aktualizuje i uruchamia OpenChamber zdalnie. Tryb zewnętrzny zakłada, że usługa już działa.', - 'settings.remoteInstances.page.field.modeManaged': 'Zarządzany (auto start)', + 'settings.remoteInstances.page.field.modeExternal': 'Już działa', + 'settings.remoteInstances.page.field.modeHint': 'Wybierz, czy OpenChamber ma uruchomić serwer za Ciebie, czy połączyć się z już działającym.', + 'settings.remoteInstances.page.field.modeManaged': 'Uruchom za mnie', 'settings.remoteInstances.page.field.modePlaceholder': 'Wybierz tryb', 'settings.remoteInstances.page.field.nickname': 'Pseudonim', - 'settings.remoteInstances.page.field.nicknamePlaceholder': 'Mój zdalny host', + 'settings.remoteInstances.page.field.nicknamePlaceholder': 'Laptop służbowy', 'settings.remoteInstances.page.field.preferredLocalPort': 'Preferowany port lokalny', - 'settings.remoteInstances.page.field.preferredLocalPortHint': 'Preferowany lokalny port dla głównego tunelu OpenChamber. Zostaw puste, aby wybrać automatycznie.', + 'settings.remoteInstances.page.field.preferredLocalPortHint': 'Lokalny port dla tego połączenia. Zostaw puste, aby wybrać automatycznie.', 'settings.remoteInstances.page.field.preferredRemotePort': 'Preferowany port zdalny', - 'settings.remoteInstances.page.field.preferredRemotePortHint': 'Port, którego OpenChamber powinien używać na zdalnym hoście. Zostaw puste, aby środowisko wybrało go automatycznie.', + 'settings.remoteInstances.page.field.preferredRemotePortHint': 'Port używany na zdalnej maszynie. Zostaw puste, aby wybrać automatycznie.', 'settings.remoteInstances.page.field.remoteHostPlaceholder': '127.0.0.1', 'settings.remoteInstances.page.field.selectBindHostPlaceholder': 'Wybierz host powiązania', 'settings.remoteInstances.page.field.selectInstallMethodPlaceholder': 'Wybierz metodę instalacji', @@ -1297,14 +1300,14 @@ export const settingsDict = { 'settings.remoteInstances.page.forwardType.dynamic': 'Dynamic (-D)', 'settings.remoteInstances.page.forwardType.local': 'Local (-L)', 'settings.remoteInstances.page.forwardType.remote': 'Remote (-R)', - 'settings.remoteInstances.page.forwardTypeDescription.dynamic': 'Udostępnij lokalny serwer proxy SOCKS5 przez SSH.', - 'settings.remoteInstances.page.forwardTypeDescription.local': 'Przekieruj ruch lokalny do zdalnego celu.', - 'settings.remoteInstances.page.forwardTypeDescription.remote': 'Udostępnij zdalny endpoint i przekieruj go z powrotem na lokalną maszynę.', + 'settings.remoteInstances.page.forwardTypeDescription.dynamic': 'Otwórz lokalny proxy SOCKS przez połączenie SSH.', + 'settings.remoteInstances.page.forwardTypeDescription.local': 'Otwórz lokalny port łączący się z usługą na zdalnej maszynie.', + 'settings.remoteInstances.page.forwardTypeDescription.remote': 'Otwórz port na zdalnej maszynie, który połączy się z powrotem z Twoim komputerem.', 'settings.remoteInstances.page.import.loading': 'Ładowanie hostów SSH...', 'settings.remoteInstances.page.import.noneAvailable': 'Brak hostów SSH dostępnych do importu.', 'settings.remoteInstances.page.import.noneFound': 'Nie znaleziono hostów SSH.', 'settings.remoteInstances.page.import.patternSuffix': '(wzorzec)', - 'settings.remoteInstances.page.import.sectionTitle': 'Importuj z konfiguracji SSH', + 'settings.remoteInstances.page.import.sectionTitle': 'Zapisane hosty SSH', 'settings.remoteInstances.page.logsDialog.empty': 'Brak logów SSH.', 'settings.remoteInstances.page.logsDialog.loading': 'Ładowanie logów...', 'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': 'Wybrana instancja', @@ -1318,7 +1321,7 @@ export const settingsDict = { 'settings.remoteInstances.page.phase.establishingSsh': 'Nawiązywanie połączenia SSH', 'settings.remoteInstances.page.phase.forwardingPorts': 'Przekierowywanie portów', 'settings.remoteInstances.page.phase.installingOpenChamber': 'Instalowanie OpenChamber', - 'settings.remoteInstances.page.phase.probingRemote': 'Sprawdzanie zdalnego hosta', + 'settings.remoteInstances.page.phase.probingRemote': 'Sprawdzanie zdalnej maszyny', 'settings.remoteInstances.page.phase.reconnecting': 'Ponowne łączenie', 'settings.remoteInstances.page.phase.resolvingConfiguration': 'Rozwiązywanie konfiguracji', 'settings.remoteInstances.page.phase.startingServer': 'Uruchamianie serwera', @@ -1327,17 +1330,17 @@ export const settingsDict = { 'settings.remoteInstances.page.preview.localSocks5': '(lokalny SOCKS5)', 'settings.remoteInstances.page.preview.remote': '(zdalny)', 'settings.remoteInstances.page.section.actions': 'Akcje', - 'settings.remoteInstances.page.section.actionsDescription': 'Połącz, połącz ponownie, sprawdź logi lub usuń tę instancję.', + 'settings.remoteInstances.page.section.actionsDescription': 'Połącz, połącz ponownie, przejrzyj logi lub usuń to połączenie.', 'settings.remoteInstances.page.section.authentication': 'Uwierzytelnianie', 'settings.remoteInstances.page.section.authenticationDescription': 'Opcjonalne dane logowania dla SSH i zdalnego interfejsu OpenChamber.', 'settings.remoteInstances.page.section.instance': 'Instancja', - 'settings.remoteInstances.page.section.instanceDescription': 'Podstawowe ustawienia SSH.', - 'settings.remoteInstances.page.section.mainTunnel': 'Główny tunel', - 'settings.remoteInstances.page.section.mainTunnelDescription': 'Główny lokalny endpoint dla tej zdalnej instancji.', + 'settings.remoteInstances.page.section.instanceDescription': 'Wybierz polecenie SSH i nazwę wyświetlaną dla tego połączenia.', + 'settings.remoteInstances.page.section.mainTunnel': 'Dostęp lokalny', + 'settings.remoteInstances.page.section.mainTunnelDescription': 'Wybierz lokalny adres używany do otwierania tego zdalnego serwera OpenChamber.', 'settings.remoteInstances.page.section.portForwards': 'Przekierowania portów', - 'settings.remoteInstances.page.section.portForwardsDescription': 'Dodatkowe przekierowania SSH poza głównym tunelem.', - 'settings.remoteInstances.page.section.remoteServer': 'Zdalny serwer', - 'settings.remoteInstances.page.section.remoteServerDescription': 'Jak OpenChamber jest zarządzany i uruchamiany na zdalnym hoście.', + 'settings.remoteInstances.page.section.portForwardsDescription': 'Opcjonalne dodatkowe porty udostępniane przez to połączenie SSH.', + 'settings.remoteInstances.page.section.remoteServer': 'OpenChamber na zdalnej maszynie', + 'settings.remoteInstances.page.section.remoteServerDescription': 'Wybierz, jak OpenChamber ma działać po połączeniu SSH.', 'settings.remoteInstances.page.status.currentLocalUrl': 'Aktualny lokalny URL:', 'settings.remoteInstances.page.status.reconnectStale': 'Ponowne łączenie wygląda na zawieszone. Możesz spróbować jeszcze raz.', 'settings.remoteInstances.page.title': 'Zdalna instancja', @@ -1354,18 +1357,18 @@ export const settingsDict = { 'settings.remoteInstances.page.toast.logsCleared': 'Logi zostały wyczyszczone', 'settings.remoteInstances.page.toast.logsCopied': 'Logi zostały skopiowane', 'settings.remoteInstances.page.toast.noLogsToCopy': 'Brak logów do skopiowania', - 'settings.remoteInstances.page.toast.openLocalEndpointFailed': 'Nie udało się otworzyć lokalnego endpointu', + 'settings.remoteInstances.page.toast.openLocalEndpointFailed': 'Nie udało się otworzyć lokalnego adresu', 'settings.remoteInstances.page.toast.removeInstanceFailed': 'Nie udało się usunąć instancji', 'settings.remoteInstances.page.toast.retryFailed': 'Nie udało się ponowić połączenia', 'settings.remoteInstances.page.toast.saveFailed': 'Nie udało się zapisać instancji', 'settings.remoteInstances.page.toast.sshCommandRequired': 'Polecenie SSH jest wymagane', - 'settings.remoteInstances.sidebar.actions.addSshInstance': 'Dodaj instancję SSH', + 'settings.remoteInstances.sidebar.actions.addSshInstance': 'Dodaj połączenie SSH', 'settings.remoteInstances.sidebar.actions.connect': 'Połącz', 'settings.remoteInstances.sidebar.actions.disconnect': 'Rozłącz', 'settings.remoteInstances.sidebar.actions.remove': 'Usuń', 'settings.remoteInstances.sidebar.actions.retry': 'Ponów', 'settings.remoteInstances.sidebar.confirm.localPortInUseRetry': 'Lokalny port jest już używany. Wybrać losowy wolny port lokalny i spróbować ponownie?', - 'settings.remoteInstances.sidebar.newSshInstanceName': 'Nowa instancja SSH', + 'settings.remoteInstances.sidebar.newSshInstanceName': 'Nowe połączenie SSH', 'settings.remoteInstances.sidebar.phase.connecting': 'Łączenie', 'settings.remoteInstances.sidebar.phase.error': 'Błąd', 'settings.remoteInstances.sidebar.phase.forwarding': 'Przekierowywanie', @@ -1377,9 +1380,40 @@ export const settingsDict = { 'settings.remoteInstances.sidebar.phase.updating': 'Aktualizowanie', 'settings.remoteInstances.sidebar.title': 'Zdalne instancje', 'settings.remoteInstances.sidebar.toast.connectFailed': 'Nie udało się połączyć z instancją', - 'settings.remoteInstances.sidebar.toast.createFailed': 'Nie udało się utworzyć instancji SSH', + 'settings.remoteInstances.sidebar.toast.createFailed': 'Nie udało się utworzyć połączenia SSH', 'settings.remoteInstances.sidebar.toast.disconnectFailed': 'Nie udało się rozłączyć instancji', 'settings.remoteInstances.sidebar.toast.removeFailed': 'Nie udało się usunąć instancji', + 'settings.remoteInstances.direct.sidebarTitle': 'Linki do serwerów', + 'settings.remoteInstances.direct.sidebarDescription': 'Połącz przez link lub token', + 'settings.remoteInstances.direct.title': 'Inne serwery OpenChamber', + 'settings.remoteInstances.direct.description': 'Dodaj inny serwer OpenChamber przez URL. Użyj tego, gdy serwer już działa i masz token połączenia.', + 'settings.remoteInstances.direct.field.labelPlaceholder': 'Etykieta (opcjonalnie)', + 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', + 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token połączenia (opcjonalny dla zaufanych serwerów lokalnych)', + 'settings.remoteInstances.direct.note': 'Tokeny połączenia są zapisywane na tym urządzeniu i używane tylko wtedy, gdy ta aplikacja łączy się z danym serwerem.', + 'settings.remoteInstances.direct.actions.add': 'Dodaj serwer', + 'settings.remoteInstances.direct.import.description': 'Wklej link połączenia z innego serwera OpenChamber.', + 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', + 'settings.remoteInstances.direct.import.action': 'Importuj link', + 'settings.remoteInstances.direct.error.invalidConnectLink': 'Nieprawidłowy link połączenia OpenChamber.', + 'settings.remoteInstances.direct.state.loading': 'Ładowanie serwerów...', + 'settings.remoteInstances.direct.state.empty': 'Nie dodano jeszcze innych serwerów.', + 'settings.remoteInstances.clientAuth.title': 'Połącz z tym serwerem', + 'settings.remoteInstances.clientAuth.description': 'Utwórz bezpieczny link lub token, aby OpenChamber Desktop mógł połączyć się z tym serwerem.', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Nazwa urządzenia (opcjonalnie)', + 'settings.remoteInstances.clientAuth.actions.create': 'Utwórz token', + 'settings.remoteInstances.clientAuth.actions.pair': 'Utwórz link', + 'settings.remoteInstances.clientAuth.actions.revoke': 'Unieważnij', + 'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Wyczyść unieważnione', + 'settings.remoteInstances.clientAuth.qrAlt': 'Kod QR połączenia OpenChamber', + 'settings.remoteInstances.clientAuth.pairingUrl': 'Link połączenia', + 'settings.remoteInstances.clientAuth.createdToken': 'Skopiuj ten token teraz. Ze względów bezpieczeństwa nie zostanie pokazany ponownie.', + 'settings.remoteInstances.clientAuth.state.loading': 'Ładowanie tokenów...', + 'settings.remoteInstances.clientAuth.state.empty': 'Nie podłączono jeszcze żadnych urządzeń.', + 'settings.remoteInstances.clientAuth.state.revoked': 'Unieważniony', + 'settings.remoteInstances.clientAuth.state.thisDevice': 'To urządzenie', + 'settings.remoteInstances.clientAuth.lastUsed': 'Ostatnio użyto {date}', + 'settings.remoteInstances.clientAuth.neverUsed': 'Nigdy nie użyto', 'settings.remoteInstances.sidebar.toast.retriedWithRandomPort': 'Ponowiono próbę z losowym lokalnym portem', 'settings.remoteInstances.sidebar.toast.retryFailed': 'Nie udało się ponowić połączenia', 'settings.remoteInstances.sidebar.total': 'Suma: {count}', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index e5c6c307..46a44616 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -28,6 +28,94 @@ export const dict: Record = { 'layout.mainTab.files': 'Pliki', 'layout.mainTab.terminal': 'Terminal', 'layout.mainTab.context': 'Kontekst', + 'mobile.nav.aria': 'Nawigacja mobilna', + 'mobile.nav.changes': 'Zmiany', + 'mobile.nav.settings': 'Ustawienia', + 'mobile.surface.closeAria': 'Zamknij', + 'mobile.header.openMenuAria': 'Otwórz menu', + 'mobile.menu.titleAria': 'Narzędzia obszaru roboczego', + 'mobile.menu.files': 'Pliki', + 'mobile.menu.changes': 'Zmiany', + 'mobile.menu.settings': 'Ustawienia', + 'mobile.sessions.newChatCta': 'Nowy czat w {project}', + 'mobile.sessions.dateGroup.today': 'Dzisiaj', + 'mobile.sessions.dateGroup.yesterday': 'Wczoraj', + 'mobile.sessions.dateGroup.thisWeek': 'Wcześniej w tym tygodniu', + 'mobile.sessions.dateGroup.older': 'Starsze', + 'mobile.sessions.section.worktrees': 'Worktrees', + 'mobile.sessions.section.otherProjects': 'Zmień projekt', + 'mobile.sessions.section.projects': 'Projekty', + 'mobile.sessions.empty.noProjectsTitle': 'Brak projektów', + 'mobile.sessions.empty.noProjectsDescription': 'Dodaj projekt, aby zacząć rozmawiać ze swoim kodem.', + 'mobile.sessions.empty.noSessionsTitle': 'Brak sesji', + 'mobile.sessions.empty.noSessionsDescription': 'Rozpocznij pierwszy czat, aby zobaczyć go tutaj.', + 'mobile.sessions.empty.searchTitle': 'Brak wyników', + 'mobile.sessions.empty.searchDescription': 'Spróbuj innego zapytania.', + 'mobile.sessions.showArchived': 'Pokaż zarchiwizowane ({count})', + 'mobile.sessions.hideArchived': 'Ukryj zarchiwizowane', + 'mobile.sessions.activeWorktreeAria': 'Aktywny worktree', + 'mobile.sessions.activeProjectAria': 'Aktywny projekt', + 'mobile.sessions.startNewChat': 'Rozpocznij nowy czat', + 'mobile.sessions.newChat': 'Nowy czat', + 'mobile.sessions.editOrder': 'Zmień kolejność projektów', + 'mobile.sessions.doneEditing': 'Gotowe', + 'mobile.sessions.editOrderHint': 'Przeciągnij uchwyt lub użyj strzałek, aby zmienić kolejność. Naciśnij znacznik, aby zakończyć.', + 'mobile.sessions.dragHandleAria': 'Przeciągnij {label}, aby zmienić kolejność', + 'mobile.sessions.moveUpAria': 'Przenieś {label} w górę', + 'mobile.sessions.moveDownAria': 'Przenieś {label} w dół', + 'mobile.sessions.removeProjectAria': 'Usuń {label}', + 'mobile.sessions.cancelRemoveProjectAria': 'Anuluj usuwanie {label}', + 'mobile.sessions.confirmRemoveProject': 'Usuń', + 'mobile.sessions.confirmRemoveProjectAria': 'Potwierdź usunięcie {label}', + 'mobile.sessions.toast.projectRemoved': 'Usunięto {label}', + 'mobile.sessions.showMore': 'Pokaż jeszcze {count}', + 'mobile.sessions.search.section.sessions': 'Sesje', + 'mobile.sessions.search.section.archived': 'Zarchiwizowane', + 'mobile.sessions.search.section.projects': 'Projekty', + 'mobile.sessions.clearSearchAria': 'Wyczyść wyszukiwanie', + 'mobile.header.noProject': 'Wybierz projekt', + 'mobile.header.activeSession': 'Aktywna sesja', + 'mobile.header.noSession': 'Brak aktywnej sesji', + 'mobile.sessions.openSheetAria': 'Otwórz sesje i projekty', + 'mobile.sessions.closeSheetAria': 'Zamknij sesje i projekty', + 'mobile.sessions.sheet.title': 'Sesje', + 'mobile.sessions.sheet.description': 'Przełączaj projekty, otwieraj sesje albo zacznij nowy czat.', + 'mobile.sessions.search.placeholder': 'Szukaj sesji', + 'mobile.sessions.empty': 'Nie znaleziono sesji.', + 'mobile.sessions.unassignedProject': 'Inne sesje', + 'mobile.sessions.newSessionAria': 'Rozpocznij nową sesję w tym projekcie', + 'mobile.sessions.untitled': 'Sesja bez tytułu', + 'mobile.sessions.project.sessionsSingle': '1 sesja', + 'mobile.sessions.project.sessionsPlural': 'Sesje: {count}', + 'mobile.files.refreshAria': 'Odśwież pliki', + 'mobile.files.backToParentAria': 'Wróć do {name}', + 'mobile.files.rootDirectory': 'Pliki projektu', + 'mobile.files.search.placeholder': 'Szukaj plików', + 'mobile.files.search.empty': 'Nie znaleziono plików.', + 'mobile.files.parentDirectory': 'Katalog nadrzędny', + 'mobile.files.empty.noDirectory': 'Wybierz projekt, aby przeglądać pliki.', + 'mobile.files.empty.directory': 'Ten katalog jest pusty.', + 'mobile.files.error.listFailed': 'Nie udało się załadować plików', + 'mobile.files.error.readUnavailable': 'Podgląd pliku jest niedostępny w tym środowisku.', + 'mobile.files.file.truncated': 'Podgląd pliku został skrócony dla trybu mobilnego.', + 'mobile.files.copyPathAria': 'Kopiuj ścieżkę pliku', + 'mobile.files.copyContent': 'Kopiuj zawartość', + 'mobile.files.copyContentAria': 'Kopiuj zawartość pliku', + 'mobile.files.toast.pathCopied': 'Ścieżka skopiowana', + 'mobile.files.toast.contentCopied': 'Zawartość skopiowana', + 'mobile.files.toast.copyFailed': 'Kopiowanie nie powiodło się', + 'mobile.changes.placeholder.title': 'Zmiany', + 'mobile.changes.placeholder.description': 'Tutaj będą przegląd drzewa roboczego, sync i commity.', + 'mobile.changes.branchLabel': 'Gałąź: {branch}', + 'mobile.changes.noRemote': 'Brak dostępnego remote', + 'mobile.changes.cleanDescription': 'W tym workspace nie ma zmienionych plików.', + 'mobile.changes.diffDetail.subtitle': 'Diff tylko do odczytu', + 'mobile.changes.diffDetail.loadFailed': 'Nie udało się wczytać diff', + 'mobile.changes.diffDetail.missingTitle': 'Plik nie jest już zmieniony', + 'mobile.changes.diffDetail.missingDescription': 'Wróć do Zmian i odśwież listę.', + 'mobile.changes.diffDetail.imageUnavailable': 'Diffy obrazów nie są jeszcze dostępne w mobilnych Zmianach.', + 'mobile.settings.placeholder.title': 'Ustawienia', + 'mobile.settings.placeholder.description': 'Tutaj będą mobilne ustawienia połączenia i aplikacji.', 'layout.rightSidebar.git': 'Git', 'layout.rightSidebar.files': 'Pliki', 'layout.rightSidebar.context': 'Kontekst', @@ -721,6 +809,8 @@ export const dict: Record = { 'onboarding.remoteConnection.actions.chooseDifferentServer': 'Wybierz inny serwer', 'onboarding.remoteConnection.actions.useLocalInstead': 'Użyj lokalnego zamiast', 'onboarding.remoteConnection.probe.authMessage': 'Serwer wymaga uwierzytelnienia. Nadal możesz się połączyć, ale może być konieczne podanie danych uwierzytelniających.', + 'onboarding.remoteConnection.probe.updateRecommendedMessage': 'Ta instancja używa innej wersji OpenChamber. Możesz się połączyć, ale zaktualizuj obie aplikacje, jeśli coś nie działa.', + 'onboarding.remoteConnection.probe.incompatibleMessage': 'Serwer uruchamia OpenChamber, ale nie jest zgodny z tą wersją aplikacji. Zaktualizuj OpenChamber na serwerze i spróbuj ponownie.', 'onboarding.remoteConnection.probe.wrongServiceMessage': 'Serwer odpowiedział, ale nie uruchomiono na nim OpenChamber. Zweryfikuj czy adres wskazuje na serwer OpenChamber.', 'onboarding.remoteConnection.probe.unreachableMessage': 'Serwer jest nieosiągalny. Sprawdź swoje połączenie sieciowe i zweryfikuj adres serwera.', 'onboarding.desktopRecovery.localUnavailable.title': 'OpenCode lokalny niedostępny', @@ -734,6 +824,8 @@ export const dict: Record = { 'onboarding.desktopRecovery.remoteUnreachable.retry': 'Ponów połączenie', 'onboarding.desktopRecovery.incompatibleServer.title': 'Niekompatybilny serwer', 'onboarding.desktopRecovery.incompatibleServer.description': 'Serwer pod adresem "{host}" nie uruchamia OpenChamber. Zweryfikuj czy adres wskazuje na serwer OpenChamber.', + 'onboarding.desktopRecovery.remoteIncompatible.title': 'Wymagana aktualizacja serwera', + 'onboarding.desktopRecovery.remoteIncompatible.description': 'Serwer OpenChamber pod adresem "{host}" nie jest zgodny z tą wersją aplikacji. Zaktualizuj OpenChamber na serwerze i spróbuj ponownie.', 'onboarding.desktopRecovery.common.useLocal': 'Użyj lokalnego', 'onboarding.desktopRecovery.common.useRemote': 'Użyj zdalnego', 'onboarding.desktopRecovery.actions.retrying': 'Ponawianie...', @@ -1257,6 +1349,9 @@ export const dict: Record = { 'desktopHostSwitcher.state.loading': 'Ładowanie...', 'desktopHostSwitcher.status.authRequired': 'Wymagane uwierzytelnienie', 'desktopHostSwitcher.status.connected': 'Połączono', + 'desktopHostSwitcher.status.checking': 'Sprawdzanie', + 'desktopHostSwitcher.status.updateRecommended': 'Zalecana aktualizacja', + 'desktopHostSwitcher.status.incompatible': 'Niekompatybilna', 'desktopHostSwitcher.status.ping': ' · {ms}ms ping', 'desktopHostSwitcher.status.unknown': 'Nieznany', 'desktopHostSwitcher.status.unreachable': 'Nieosiągalna', @@ -1817,6 +1912,12 @@ export const dict: Record = { 'header.services.modelFamily.other': 'Inne', 'header.services.noRateLimits': 'Brak dostępnych limitów użycia.', 'header.services.noRateLimitsReported': 'Nie zgłoszono limitów użycia.', + 'header.services.remoteUpdate.title': 'Aktualizacja zdalnej instancji', + 'header.services.remoteUpdate.checking': 'Sprawdzanie aktualizacji...', + 'header.services.remoteUpdate.upToDate': 'Ta instancja jest aktualna.', + 'header.services.remoteUpdate.available': 'Wersja {version} jest dostępna dla tej instancji.', + 'header.services.remoteUpdate.error': 'Nie udało się sprawdzić aktualizacji zdalnej instancji', + 'header.services.remoteUpdate.actions.open': 'Aktualizuj', 'header.services.open': 'Otwórz instancję, użycie i MCP', 'header.services.openWithCurrent': 'Otwórz instancję, użycie i MCP (bieżąca: {current})', 'header.services.rateLimits': 'Limity użycia', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 7a66e623..c3e94ad5 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -198,19 +198,50 @@ export const settingsDict = { "settings.magicPrompts.sidebar.item.sessionFusion": "Fusion", "settings.remoteInstances.sidebar.title": "Instâncias remotas", "settings.remoteInstances.sidebar.total": "Total {count}", - "settings.remoteInstances.sidebar.newSshInstanceName": "Nova instância SSH", - "settings.remoteInstances.sidebar.actions.addSshInstance": "Adicionar instância SSH", + "settings.remoteInstances.sidebar.newSshInstanceName": "Nova conexão SSH", + "settings.remoteInstances.sidebar.actions.addSshInstance": "Adicionar conexão SSH", "settings.remoteInstances.sidebar.actions.connect": "Conectar", "settings.remoteInstances.sidebar.actions.disconnect": "Desconectar", "settings.remoteInstances.sidebar.actions.retry": "Tentar novamente", "settings.remoteInstances.sidebar.actions.remove": "Excluir", "settings.remoteInstances.sidebar.confirm.localPortInUseRetry": "A porta local já está em uso. Escolher uma porta livre aleatória e tentar novamente?", - "settings.remoteInstances.sidebar.toast.createFailed": "Não foi possível criar a instância SSH", + "settings.remoteInstances.sidebar.toast.createFailed": "Não foi possível criar a conexão SSH", "settings.remoteInstances.sidebar.toast.retriedWithRandomPort": "Tentado novamente com uma porta local aleatória", "settings.remoteInstances.sidebar.toast.connectFailed": "Não foi possível conectar a instância", "settings.remoteInstances.sidebar.toast.disconnectFailed": "Não foi possível desconectar a instância", "settings.remoteInstances.sidebar.toast.retryFailed": "Não foi possível tentar a conexão novamente", "settings.remoteInstances.sidebar.toast.removeFailed": "Não foi possível excluir a instância", + "settings.remoteInstances.direct.sidebarTitle": "Links de servidores", + "settings.remoteInstances.direct.sidebarDescription": "Conecte com um link ou token", + "settings.remoteInstances.direct.title": "Outros servidores OpenChamber", + "settings.remoteInstances.direct.description": "Adicione outro servidor OpenChamber por URL. Use isto quando o servidor já estiver em execução e você tiver um token de conexão.", + "settings.remoteInstances.direct.field.labelPlaceholder": "Rótulo (opcional)", + "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", + "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexão (opcional para servidores locais confiáveis)", + "settings.remoteInstances.direct.note": "Os tokens de conexão ficam salvos neste dispositivo e são usados apenas quando este app se conecta a esse servidor.", + "settings.remoteInstances.direct.actions.add": "Adicionar servidor", + "settings.remoteInstances.direct.import.description": "Cole um link de conexão de outro servidor OpenChamber.", + "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", + "settings.remoteInstances.direct.import.action": "Importar link", + "settings.remoteInstances.direct.error.invalidConnectLink": "Link de conexão do OpenChamber inválido.", + "settings.remoteInstances.direct.state.loading": "Carregando servidores...", + "settings.remoteInstances.direct.state.empty": "Nenhum outro servidor adicionado ainda.", + "settings.remoteInstances.clientAuth.title": "Conectar a este servidor", + "settings.remoteInstances.clientAuth.description": "Crie um link ou token seguro para que o OpenChamber Desktop possa se conectar a este servidor.", + "settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nome do dispositivo (opcional)", + "settings.remoteInstances.clientAuth.actions.create": "Criar token", + "settings.remoteInstances.clientAuth.actions.pair": "Criar link", + "settings.remoteInstances.clientAuth.actions.revoke": "Revogar", + "settings.remoteInstances.clientAuth.actions.clearRevoked": "Limpar revogados", + "settings.remoteInstances.clientAuth.qrAlt": "OpenChamber connection QR code", + "settings.remoteInstances.clientAuth.pairingUrl": "Link de conexão", + "settings.remoteInstances.clientAuth.createdToken": "Copie este token agora. Por segurança, ele não será mostrado novamente.", + "settings.remoteInstances.clientAuth.state.loading": "Carregando tokens...", + "settings.remoteInstances.clientAuth.state.empty": "Nenhum dispositivo conectado ainda.", + "settings.remoteInstances.clientAuth.state.revoked": "Revogado", + "settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo", + "settings.remoteInstances.clientAuth.lastUsed": "Último uso em {date}", + "settings.remoteInstances.clientAuth.neverUsed": "Nunca usado", "settings.remoteInstances.sidebar.phase.ready": "Pronto", "settings.remoteInstances.sidebar.phase.error": "Erro", "settings.remoteInstances.sidebar.phase.reconnect": "Reconectar", @@ -221,20 +252,20 @@ export const settingsDict = { "settings.remoteInstances.sidebar.phase.connecting": "Conectando", "settings.remoteInstances.sidebar.phase.idle": "Inativo", "settings.remoteInstances.page.section.instance": "Instância", - "settings.remoteInstances.page.section.instanceDescription": "Configurações básicas de SSH.", + "settings.remoteInstances.page.section.instanceDescription": "Choose the SSH command and a display name for this connection.", "settings.remoteInstances.page.field.mode": "Modo", - "settings.remoteInstances.page.field.modeHint": "Instalações/atualizações gerenciadas e inicialização remota do OpenChamber. Externo assume que ele já está em execução.", + "settings.remoteInstances.page.field.modeHint": "Escolha se o OpenChamber deve iniciar o servidor para você ou se conectar a um que já está em execução.", "settings.remoteInstances.page.field.modePlaceholder": "Selecionar modo", - "settings.remoteInstances.page.field.modeManaged": "Gerenciado (inicialização automática)", - "settings.remoteInstances.page.field.modeExternal": "Externo (já em execução)", + "settings.remoteInstances.page.field.modeManaged": "Iniciar para mim", + "settings.remoteInstances.page.field.modeExternal": "Já está em execução", "settings.remoteInstances.page.field.preferredRemotePort": "Porta remoto preferido", - "settings.remoteInstances.page.field.preferredRemotePortHint": "Porta que o OpenChamber deve usar no host remoto. Deixe vazio para que o ambiente escolha.", + "settings.remoteInstances.page.field.preferredRemotePortHint": "Port to use on the remote machine. Leave empty to choose one automatically.", "settings.remoteInstances.page.field.keepServerRunning": "Manter servidor em execução", - "settings.remoteInstances.page.field.keepServerRunningHint": "Quando habilitado, o daemon do OpenChamber permanece em execução remotamente ao desconectar.", + "settings.remoteInstances.page.field.keepServerRunningHint": "Keep OpenChamber running on the remote machine after you disconnect.", "settings.remoteInstances.page.field.bindHost": "Host de link", - "settings.remoteInstances.page.field.bindHostHint": "Interface de rede para a URL local principal. Use 127.0.0.1/localhost somente para acesso local.", + "settings.remoteInstances.page.field.bindHostHint": "Where the local connection should listen. Use 127.0.0.1 or localhost unless you need LAN access.", "settings.remoteInstances.page.field.preferredLocalPort": "Porta local preferido", - "settings.remoteInstances.page.field.preferredLocalPortHint": "Porta local preferida para o túnel principal do OpenChamber. Deixe vazio para seleção automática.", + "settings.remoteInstances.page.field.preferredLocalPortHint": "Local port to open for this connection. Leave empty to choose one automatically.", "settings.remoteInstances.page.field.forwardType": "Tipo de encaminhamento", "settings.remoteInstances.page.field.localHostPlaceholder": "127.0.0.1", "settings.remoteInstances.page.field.remoteHostPlaceholder": "127.0.0.1", @@ -243,7 +274,7 @@ export const settingsDict = { "settings.remoteInstances.page.preview.localSocks5": "(local SOCKS5)", "settings.remoteInstances.page.preview.local": "(local)", "settings.remoteInstances.page.preview.remote": "(remoto)", - "settings.remoteInstances.page.toast.openLocalEndpointFailed": "Não foi possível abrir o endpoint local", + "settings.remoteInstances.page.toast.openLocalEndpointFailed": "Não foi possível abrir o endereço local", "settings.remoteInstances.page.toast.localUrlCopied": "URL local copiada", "settings.remoteInstances.page.actions.copyLocalUrl": "Copiar URL local", "settings.remoteInstances.page.actions.open": "Abrir", @@ -945,26 +976,26 @@ export const settingsDict = { "settings.usage.pace.waitSeparator": " · Aguarder ", "settings.usage.pace.predictionLabel": "Prede.: ", "settings.remoteInstances.page.title": "Instância remota", - "settings.remoteInstances.page.description": "Configure a conexão SSH, o servidor remoto e a configurações de redeirección.", + "settings.remoteInstances.page.description": "Conecte-se a outra máquina por SSH e abra o OpenChamber lá.", "settings.remoteInstances.page.empty.selectInstance": "Selecione uma instância para ver e editar suas configuraciones.", "settings.remoteInstances.page.empty.noExtraForwards": "Não há redeirecciones adicionales de porta configuradas.", "settings.remoteInstances.page.section.actions": "Ações", - "settings.remoteInstances.page.section.actionsDescription": "Conectar, reconectar, inspecionar logs ou excluir esta instância.", - "settings.remoteInstances.page.section.remoteServer": "Servidor remoto", - "settings.remoteInstances.page.section.remoteServerDescription": "Como o OpenChamber é gerenciado e iniciado no host remoto.", - "settings.remoteInstances.page.section.mainTunnel": "Túnel principal", - "settings.remoteInstances.page.section.mainTunnelDescription": "Ponto de conexão local principal para esta instância remota.", + "settings.remoteInstances.page.section.actionsDescription": "Conecte, reconecte, veja logs ou remova esta conexão.", + "settings.remoteInstances.page.section.remoteServer": "OpenChamber na máquina remota", + "settings.remoteInstances.page.section.remoteServerDescription": "Escolha como o OpenChamber deve rodar depois que o SSH conectar.", + "settings.remoteInstances.page.section.mainTunnel": "Acesso local", + "settings.remoteInstances.page.section.mainTunnelDescription": "Escolha o endereço local usado para abrir este servidor OpenChamber remoto.", "settings.remoteInstances.page.section.authentication": "Autenticação", "settings.remoteInstances.page.section.authenticationDescription": "Credenciais opcionais para SSH e para a interface de usuário do OpenChamber remoto.", "settings.remoteInstances.page.section.portForwards": "Redeirecciones de porta", - "settings.remoteInstances.page.section.portForwardsDescription": "Redirecionamentos SSH adicionais além do túnel principal.", + "settings.remoteInstances.page.section.portForwardsDescription": "Portas extras opcionais para disponibilizar por esta conexão SSH.", "settings.remoteInstances.page.field.sshCommand": "Comando SSH", "settings.remoteInstances.page.field.sshCommandPlaceholder": "ssh user@host", "settings.remoteInstances.page.field.nickname": "Apodo", - "settings.remoteInstances.page.field.nicknamePlaceholder": "Mi host remoto", + "settings.remoteInstances.page.field.nicknamePlaceholder": "Notebook do trabalho", "settings.remoteInstances.page.field.connectionTimeoutSeconds": "Tempo limite de conexão (segundos)", "settings.remoteInstances.page.field.installMethod": "Método de instalação", - "settings.remoteInstances.page.field.installMethodHint": "Cómo se instala OpenChamber quando se executa em modo gerenciado.", + "settings.remoteInstances.page.field.installMethodHint": "Como o OpenChamber deve ser colocado na máquina remota quando este app o inicia para você.", "settings.remoteInstances.page.field.selectInstallMethodPlaceholder": "Selecionar método de instalação", "settings.remoteInstances.page.field.installMethodDownloadRelease": "Baixar versão", "settings.remoteInstances.page.field.installMethodUploadBundle": "Enviar paquete", @@ -973,14 +1004,14 @@ export const settingsDict = { "settings.remoteInstances.page.field.sshPasswordPlaceholder": "Introducir senha SSH", "settings.remoteInstances.page.field.uiPasswordOptional": "Senha da interface de usuário (opcional)", "settings.remoteInstances.page.field.uiPasswordPlaceholder": "Digite a senha da interface de usuário", - "settings.remoteInstances.page.field.forwardTypeHint": "Escolha redeirección local (-L), remota (-R) o dinámica (-D).", + "settings.remoteInstances.page.field.forwardTypeHint": "Escolha que tipo de acesso a portas esta conexão SSH deve oferecer.", "settings.remoteInstances.page.field.typePlaceholder": "Tipo", "settings.remoteInstances.page.forwardType.local": "Local (-L)", "settings.remoteInstances.page.forwardType.remote": "Remota (-R)", "settings.remoteInstances.page.forwardType.dynamic": "Dinámica (-D)", - "settings.remoteInstances.page.forwardTypeDescription.local": "Redeirige o tráfico local a um destino remoto.", - "settings.remoteInstances.page.forwardTypeDescription.remote": "Expón um endpoint remoto e redeirígelo de vueltà sua máquina local.", - "settings.remoteInstances.page.forwardTypeDescription.dynamic": "Expón um proxy SOCKS5 local sobre SSH.", + "settings.remoteInstances.page.forwardTypeDescription.local": "Abre uma porta local que se conecta a algo na máquina remota.", + "settings.remoteInstances.page.forwardTypeDescription.remote": "Abre uma porta na máquina remota que se conecta de volta ao seu computador.", + "settings.remoteInstances.page.forwardTypeDescription.dynamic": "Abre um proxy SOCKS local pela conexão SSH.", "settings.remoteInstances.page.actions.create": "Criar", "settings.remoteInstances.page.actions.cancel": "Cancelar", "settings.remoteInstances.page.actions.connecting": "Conectando...", @@ -991,7 +1022,7 @@ export const settingsDict = { "settings.remoteInstances.page.actions.enableForwardAria": "Ativar redeirección", "settings.remoteInstances.page.actions.openLocal": "Abrir local", "settings.remoteInstances.page.actions.addForward": "Adicionar redeirección", - "settings.remoteInstances.page.import.sectionTitle": "Importar de configurações SSH", + "settings.remoteInstances.page.import.sectionTitle": "Hosts SSH salvos", "settings.remoteInstances.page.import.loading": "Carregando hosts SSH...", "settings.remoteInstances.page.import.noneFound": "Nenhum host SSH encontrado.", "settings.remoteInstances.page.import.noneAvailable": "Não há hosts SSH disponíveis para importar.", @@ -1006,7 +1037,7 @@ export const settingsDict = { "settings.remoteInstances.page.phase.resolvingConfiguration": "Resolviendo configurações", "settings.remoteInstances.page.phase.checkingAuth": "Verificando autenticação", "settings.remoteInstances.page.phase.establishingSsh": "Estableciendo conexão SSH", - "settings.remoteInstances.page.phase.probingRemote": "Explorando host remoto", + "settings.remoteInstances.page.phase.probingRemote": "Verificando máquina remota", "settings.remoteInstances.page.phase.installingOpenChamber": "Instalando OpenChamber", "settings.remoteInstances.page.phase.updatingOpenChamber": "Atualizando OpenChamber", "settings.remoteInstances.page.phase.detectingServer": "Detectando servidor", @@ -1471,6 +1502,9 @@ export const settingsDict = { "settings.voice.page.preview.voiceLine": "Olá! Sou {voiceName}. É assim que minha voz soa.", "settings.voice.page.preview.customServerLine": "Olá! Esta é uma prévia do servidor TTS personalizado.", "settings.openchamber.visual.section.colorMode": "Modo de cor", + "settings.openchamber.visual.section.mobileLayout": "Layout móvel", + "settings.openchamber.visual.option.mobileLayout.default": "Padrão", + "settings.openchamber.visual.option.mobileLayout.new": "Novo", "settings.openchamber.visual.section.localization": "Localização", "settings.openchamber.visual.section.spacingAndLayout": "Espaçamento e layout", "settings.openchamber.visual.section.navigation": "Navegação", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index e339a8a3..e30e083d 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -27,6 +27,94 @@ export const dict: Record = { "layout.mainTab.files": "Arquivos", "layout.mainTab.terminal": "Terminal", "layout.mainTab.context": "Contexto", + "mobile.nav.aria": "Navegação móvel", + "mobile.nav.changes": "Alterações", + "mobile.nav.settings": "Configurações", + "mobile.surface.closeAria": "Fechar", + "mobile.header.openMenuAria": "Abrir menu", + "mobile.menu.titleAria": "Ferramentas do espaço de trabalho", + "mobile.menu.files": "Arquivos", + "mobile.menu.changes": "Alterações", + "mobile.menu.settings": "Configurações", + "mobile.sessions.newChatCta": "Novo chat em {project}", + "mobile.sessions.dateGroup.today": "Hoje", + "mobile.sessions.dateGroup.yesterday": "Ontem", + "mobile.sessions.dateGroup.thisWeek": "Início desta semana", + "mobile.sessions.dateGroup.older": "Mais antigos", + "mobile.sessions.section.worktrees": "Worktrees", + "mobile.sessions.section.otherProjects": "Trocar de projeto", + "mobile.sessions.section.projects": "Projetos", + "mobile.sessions.empty.noProjectsTitle": "Sem projetos", + "mobile.sessions.empty.noProjectsDescription": "Adicione um projeto para começar a conversar com seu código.", + "mobile.sessions.empty.noSessionsTitle": "Sem sessões", + "mobile.sessions.empty.noSessionsDescription": "Inicie seu primeiro chat para vê-lo aqui.", + "mobile.sessions.empty.searchTitle": "Sem resultados", + "mobile.sessions.empty.searchDescription": "Tente outro termo de busca.", + "mobile.sessions.showArchived": "Mostrar arquivadas ({count})", + "mobile.sessions.hideArchived": "Ocultar arquivadas", + "mobile.sessions.activeWorktreeAria": "Worktree ativa", + "mobile.sessions.activeProjectAria": "Projeto ativo", + "mobile.sessions.startNewChat": "Iniciar novo chat", + "mobile.sessions.newChat": "Novo chat", + "mobile.sessions.editOrder": "Reordenar projetos", + "mobile.sessions.doneEditing": "Concluído", + "mobile.sessions.editOrderHint": "Arraste a alça ou use as setas para reordenar os projetos. Toque na marca para finalizar.", + "mobile.sessions.dragHandleAria": "Arrastar {label} para reordenar", + "mobile.sessions.moveUpAria": "Mover {label} para cima", + "mobile.sessions.moveDownAria": "Mover {label} para baixo", + "mobile.sessions.removeProjectAria": "Remover {label}", + "mobile.sessions.cancelRemoveProjectAria": "Cancelar remoção de {label}", + "mobile.sessions.confirmRemoveProject": "Excluir", + "mobile.sessions.confirmRemoveProjectAria": "Confirmar remoção de {label}", + "mobile.sessions.toast.projectRemoved": "Removido {label}", + "mobile.sessions.showMore": "Mostrar mais {count}", + "mobile.sessions.search.section.sessions": "Sessões", + "mobile.sessions.search.section.archived": "Arquivadas", + "mobile.sessions.search.section.projects": "Projetos", + "mobile.sessions.clearSearchAria": "Limpar busca", + "mobile.header.noProject": "Selecione um projeto", + "mobile.header.activeSession": "Sessão ativa", + "mobile.header.noSession": "Nenhuma sessão ativa", + "mobile.sessions.openSheetAria": "Abrir sessões e projetos", + "mobile.sessions.closeSheetAria": "Fechar sessões e projetos", + "mobile.sessions.sheet.title": "Sessões", + "mobile.sessions.sheet.description": "Alterne projetos, abra sessões ou inicie um novo chat.", + "mobile.sessions.search.placeholder": "Pesquisar sessões", + "mobile.sessions.empty": "Nenhuma sessão encontrada.", + "mobile.sessions.unassignedProject": "Outras sessões", + "mobile.sessions.newSessionAria": "Iniciar uma nova sessão neste projeto", + "mobile.sessions.untitled": "Sessão sem título", + "mobile.sessions.project.sessionsSingle": "1 sessão", + "mobile.sessions.project.sessionsPlural": "{count} sessões", + "mobile.files.refreshAria": "Atualizar arquivos", + "mobile.files.backToParentAria": "Voltar para {name}", + "mobile.files.rootDirectory": "Arquivos do projeto", + "mobile.files.search.placeholder": "Buscar arquivos", + "mobile.files.search.empty": "Nenhum arquivo encontrado.", + "mobile.files.parentDirectory": "Diretório pai", + "mobile.files.empty.noDirectory": "Selecione um projeto para navegar pelos arquivos.", + "mobile.files.empty.directory": "Este diretório está vazio.", + "mobile.files.error.listFailed": "Falha ao carregar arquivos", + "mobile.files.error.readUnavailable": "A prévia do arquivo não está disponível neste runtime.", + "mobile.files.file.truncated": "Prévia do arquivo truncada para mobile.", + "mobile.files.copyPathAria": "Copiar caminho do arquivo", + "mobile.files.copyContent": "Copiar conteúdo", + "mobile.files.copyContentAria": "Copiar conteúdo do arquivo", + "mobile.files.toast.pathCopied": "Caminho copiado", + "mobile.files.toast.contentCopied": "Conteúdo copiado", + "mobile.files.toast.copyFailed": "Falha ao copiar", + "mobile.changes.placeholder.title": "Alterações", + "mobile.changes.placeholder.description": "Revisão da árvore de trabalho, sync e commits ficarão aqui.", + "mobile.changes.branchLabel": "Branch: {branch}", + "mobile.changes.noRemote": "Nenhum remote disponível", + "mobile.changes.cleanDescription": "Não há arquivos alterados neste workspace.", + "mobile.changes.diffDetail.subtitle": "Diff somente leitura", + "mobile.changes.diffDetail.loadFailed": "Falha ao carregar diff", + "mobile.changes.diffDetail.missingTitle": "O arquivo não está mais alterado", + "mobile.changes.diffDetail.missingDescription": "Volte para Alterações e atualize a lista.", + "mobile.changes.diffDetail.imageUnavailable": "Diffs de imagem ainda não estão disponíveis nas Alterações móveis.", + "mobile.settings.placeholder.title": "Configurações", + "mobile.settings.placeholder.description": "Configurações móveis de conexão e aplicativo ficarão aqui.", "layout.rightSidebar.git": "Git", "layout.rightSidebar.files": "Arquivos", "layout.rightSidebar.context": "Contexto", @@ -1125,6 +1213,12 @@ export const dict: Record = { "header.services.refreshRateLimitsAria": "Atualizar limites de taxa", "header.services.noRateLimits": "Não há limites de taxa disponíveis.", "header.services.noRateLimitsReported": "Nenhum limite de taxa foi informado.", + "header.services.remoteUpdate.title": "Atualização da instância remota", + "header.services.remoteUpdate.checking": "Procurando atualizações...", + "header.services.remoteUpdate.upToDate": "Esta instância está atualizada.", + "header.services.remoteUpdate.available": "A versão {version} está disponível para esta instância.", + "header.services.remoteUpdate.error": "Não foi possível verificar atualizações da instância remota", + "header.services.remoteUpdate.actions.open": "Atualizar", "header.services.used": "Usado", "header.services.remaining": "Restante", "header.services.modelFamily.other": "Outro", @@ -2035,6 +2129,9 @@ export const dict: Record = { "desktopHostSwitcher.header.currentDefaultColon": "Padrão atual:", "desktopHostSwitcher.status.connected": "Conectado", "desktopHostSwitcher.status.authRequired": "Autenticação obrigatória", + "desktopHostSwitcher.status.checking": "Verificando", + "desktopHostSwitcher.status.updateRecommended": "Atualização recomendada", + "desktopHostSwitcher.status.incompatible": "Incompatível", "desktopHostSwitcher.status.wrongService": "Serviço incorreto", "desktopHostSwitcher.status.unreachable": "Inacessível", "desktopHostSwitcher.status.unknown": "Desconhecido", @@ -2221,6 +2318,8 @@ export const dict: Record = { "onboarding.remoteConnection.actions.chooseDifferentServer": "Escolher outro servidor", "onboarding.remoteConnection.actions.useLocalInstead": "Usar Local no lugar", "onboarding.remoteConnection.probe.authMessage": "O servidor exige autenticação. Você ainda pode se conectar, mas talvez precise fornecer credenciais.", + "onboarding.remoteConnection.probe.updateRecommendedMessage": "Esta instância está executando uma versão diferente do OpenChamber. Você pode se conectar, mas atualize os dois apps se algo não funcionar.", + "onboarding.remoteConnection.probe.incompatibleMessage": "O servidor está executando OpenChamber, mas não é compatível com esta versão do app. Atualize o OpenChamber no servidor e tente novamente.", "onboarding.remoteConnection.probe.wrongServiceMessage": "O servidor respondeu, mas não está executando OpenChamber. Verifique se o endereço aponta para um servidor OpenChamber.", "onboarding.remoteConnection.probe.unreachableMessage": "O servidor não está disponível. Revise sua conexão de rede e verifique o endereço do servidor.", "onboarding.desktopRecovery.localUnavailable.title": "OpenCode Local não disponível", @@ -2234,6 +2333,8 @@ export const dict: Record = { "onboarding.desktopRecovery.remoteUnreachable.retry": "Tentar novamente conexão", "onboarding.desktopRecovery.incompatibleServer.title": "Servidor incompatível", "onboarding.desktopRecovery.incompatibleServer.description": "O servidor em \"{host}\" não está executando OpenChamber. Verifique se o endereço aponta para um servidor OpenChamber.", + "onboarding.desktopRecovery.remoteIncompatible.title": "Atualização do servidor necessária", + "onboarding.desktopRecovery.remoteIncompatible.description": "O servidor OpenChamber em \"{host}\" não é compatível com esta versão do app. Atualize o OpenChamber no servidor e tente novamente.", "onboarding.desktopRecovery.common.useLocal": "Usar Local", "onboarding.desktopRecovery.common.useRemote": "Usar remoto", "onboarding.desktopRecovery.actions.retrying": "Retentendo…", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 975d4f91..8f49dcaa 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -198,19 +198,50 @@ export const settingsDict = { "settings.magicPrompts.sidebar.item.sessionFusion": "Fusion", "settings.remoteInstances.sidebar.title": "Віддалені інстанси", "settings.remoteInstances.sidebar.total": "Усього {count}", - "settings.remoteInstances.sidebar.newSshInstanceName": "Новий інстанс SSH", - "settings.remoteInstances.sidebar.actions.addSshInstance": "Додати інстанс SSH", + "settings.remoteInstances.sidebar.newSshInstanceName": "Нове SSH-підключення", + "settings.remoteInstances.sidebar.actions.addSshInstance": "Додати SSH-підключення", "settings.remoteInstances.sidebar.actions.connect": "Підключитися", "settings.remoteInstances.sidebar.actions.disconnect": "Відключити", "settings.remoteInstances.sidebar.actions.retry": "Повторити спробу", "settings.remoteInstances.sidebar.actions.remove": "Видалити", "settings.remoteInstances.sidebar.confirm.localPortInUseRetry": "Локальний порт уже використовується. Вибрати випадковий вільний локальний порт і повторити спробу?", - "settings.remoteInstances.sidebar.toast.createFailed": "Не вдалося створити інстанс SSH", + "settings.remoteInstances.sidebar.toast.createFailed": "Не вдалося створити SSH-підключення", "settings.remoteInstances.sidebar.toast.retriedWithRandomPort": "Повторна спроба з випадковим локальним портом", "settings.remoteInstances.sidebar.toast.connectFailed": "Не вдалося підключити інстанс", "settings.remoteInstances.sidebar.toast.disconnectFailed": "Не вдалося від’єднати інстанс", "settings.remoteInstances.sidebar.toast.retryFailed": "Не вдалося повторити підключення", "settings.remoteInstances.sidebar.toast.removeFailed": "Не вдалося видалити інстанс", + "settings.remoteInstances.direct.sidebarTitle": "Посилання на сервери", + "settings.remoteInstances.direct.sidebarDescription": "Підключення через посилання або токен", + "settings.remoteInstances.direct.title": "Інші сервери OpenChamber", + "settings.remoteInstances.direct.description": "Додайте інший сервер OpenChamber за URL. Використовуйте це, коли сервер уже запущений і у вас є токен підключення.", + "settings.remoteInstances.direct.field.labelPlaceholder": "Назва (необов’язково)", + "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", + "settings.remoteInstances.direct.field.tokenPlaceholder": "Токен підключення (необов’язково для довірених локальних серверів)", + "settings.remoteInstances.direct.note": "Токени підключення зберігаються на цьому пристрої й використовуються лише коли цей застосунок підключається до відповідного сервера.", + "settings.remoteInstances.direct.actions.add": "Додати сервер", + "settings.remoteInstances.direct.import.description": "Вставте посилання для підключення з іншого сервера OpenChamber.", + "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", + "settings.remoteInstances.direct.import.action": "Імпортувати посилання", + "settings.remoteInstances.direct.error.invalidConnectLink": "Недійсне посилання підключення OpenChamber.", + "settings.remoteInstances.direct.state.loading": "Завантаження серверів...", + "settings.remoteInstances.direct.state.empty": "Інших серверів ще не додано.", + "settings.remoteInstances.clientAuth.title": "Підключення до цього сервера", + "settings.remoteInstances.clientAuth.description": "Створіть безпечне посилання або токен, щоб OpenChamber Desktop міг підключитися до цього сервера.", + "settings.remoteInstances.clientAuth.field.labelPlaceholder": "Назва пристрою (необов’язково)", + "settings.remoteInstances.clientAuth.actions.create": "Створити токен", + "settings.remoteInstances.clientAuth.actions.pair": "Створити посилання", + "settings.remoteInstances.clientAuth.actions.revoke": "Відкликати", + "settings.remoteInstances.clientAuth.actions.clearRevoked": "Очистити відкликані", + "settings.remoteInstances.clientAuth.qrAlt": "QR-код підключення OpenChamber", + "settings.remoteInstances.clientAuth.pairingUrl": "Посилання для підключення", + "settings.remoteInstances.clientAuth.createdToken": "Скопіюйте цей токен зараз. З міркувань безпеки він більше не показуватиметься.", + "settings.remoteInstances.clientAuth.state.loading": "Завантаження токенів...", + "settings.remoteInstances.clientAuth.state.empty": "Жоден пристрій ще не підключено.", + "settings.remoteInstances.clientAuth.state.revoked": "Відкликано", + "settings.remoteInstances.clientAuth.state.thisDevice": "Цей пристрій", + "settings.remoteInstances.clientAuth.lastUsed": "Останнє використання {date}", + "settings.remoteInstances.clientAuth.neverUsed": "Ще не використовувався", "settings.remoteInstances.sidebar.phase.ready": "Готово", "settings.remoteInstances.sidebar.phase.error": "Помилка", "settings.remoteInstances.sidebar.phase.reconnect": "Повторне підключення", @@ -221,20 +252,20 @@ export const settingsDict = { "settings.remoteInstances.sidebar.phase.connecting": "Підключення", "settings.remoteInstances.sidebar.phase.idle": "Очікує", "settings.remoteInstances.page.section.instance": "Інстанс", - "settings.remoteInstances.page.section.instanceDescription": "Основні налаштування SSH.", + "settings.remoteInstances.page.section.instanceDescription": "Виберіть SSH-команду й назву для цього підключення.", "settings.remoteInstances.page.field.mode": "Режим", - "settings.remoteInstances.page.field.modeHint": "Керований режим встановлює/оновлює OpenChamber і запускає його віддалено. Зовнішній режим очікує, що сервер уже запущено.", + "settings.remoteInstances.page.field.modeHint": "Виберіть, чи OpenChamber має запустити сервер для вас, чи підключитися до вже запущеного.", "settings.remoteInstances.page.field.modePlaceholder": "Виберіть режим", - "settings.remoteInstances.page.field.modeManaged": "Керований (автоматичний запуск)", - "settings.remoteInstances.page.field.modeExternal": "Зовнішній (вже працює)", + "settings.remoteInstances.page.field.modeManaged": "Запустити для мене", + "settings.remoteInstances.page.field.modeExternal": "Уже запущено", "settings.remoteInstances.page.field.preferredRemotePort": "Бажаний віддалений порт", - "settings.remoteInstances.page.field.preferredRemotePortHint": "Порт OpenChamber на віддаленому хості. Залиште порожнім для автоматичного вибору під час запуску.", + "settings.remoteInstances.page.field.preferredRemotePortHint": "Порт на віддаленій машині. Залиште порожнім, щоб вибрати автоматично.", "settings.remoteInstances.page.field.keepServerRunning": "Залишати сервер запущеним", - "settings.remoteInstances.page.field.keepServerRunningHint": "Якщо ввімкнено, демон OpenChamber продовжує працювати віддалено після відключення.", + "settings.remoteInstances.page.field.keepServerRunningHint": "Залишати OpenChamber запущеним на віддаленій машині після відключення.", "settings.remoteInstances.page.field.bindHost": "Прив’язати хост", - "settings.remoteInstances.page.field.bindHostHint": "Мережевий інтерфейс для основної локальної URL-адреси. Використовуйте 127.0.0.1/localhost лише для локального доступу.", + "settings.remoteInstances.page.field.bindHostHint": "Де має слухати локальне підключення. Використовуйте 127.0.0.1 або localhost, якщо вам не потрібен доступ з локальної мережі.", "settings.remoteInstances.page.field.preferredLocalPort": "Бажаний локальний порт", - "settings.remoteInstances.page.field.preferredLocalPortHint": "Бажаний локальний порт для головного тунелю OpenChamber. Залиште порожнім для автоматичного вибору.", + "settings.remoteInstances.page.field.preferredLocalPortHint": "Локальний порт для цього підключення. Залиште порожнім, щоб вибрати автоматично.", "settings.remoteInstances.page.field.forwardType": "Тип переадресації", "settings.remoteInstances.page.field.localHostPlaceholder": "127.0.0.1", "settings.remoteInstances.page.field.remoteHostPlaceholder": "127.0.0.1", @@ -243,7 +274,7 @@ export const settingsDict = { "settings.remoteInstances.page.preview.localSocks5": "(локальний SOCKS5)", "settings.remoteInstances.page.preview.local": "(локальний)", "settings.remoteInstances.page.preview.remote": "(віддалений)", - "settings.remoteInstances.page.toast.openLocalEndpointFailed": "Не вдалося відкрити локальну кінцеву точку", + "settings.remoteInstances.page.toast.openLocalEndpointFailed": "Не вдалося відкрити локальну адресу", "settings.remoteInstances.page.toast.localUrlCopied": "Локальний URL скопійовано", "settings.remoteInstances.page.actions.copyLocalUrl": "Скопіювати локальний URL", "settings.remoteInstances.page.actions.open": "Відкрити", @@ -945,26 +976,26 @@ export const settingsDict = { "settings.usage.pace.waitSeparator": " · Зачекайте ", "settings.usage.pace.predictionLabel": "Прогноз: ", "settings.remoteInstances.page.title": "Віддалений інстанс", - "settings.remoteInstances.page.description": "Налаштувати підключення SSH, віддалений сервер і параметри пересилання.", + "settings.remoteInstances.page.description": "Підключіться до іншої машини через SSH і відкрийте там OpenChamber.", "settings.remoteInstances.page.empty.selectInstance": "Виберіть інстанс, щоб переглянути та змінити його налаштування.", "settings.remoteInstances.page.empty.noExtraForwards": "Не налаштовано додаткові переадресації портів.", "settings.remoteInstances.page.section.actions": "Дії", - "settings.remoteInstances.page.section.actionsDescription": "Підключіться, перепідключіться, перегляньте журнали або видаліть цей інстанс.", - "settings.remoteInstances.page.section.remoteServer": "Віддалений сервер", - "settings.remoteInstances.page.section.remoteServerDescription": "Як OpenChamber керується та запускається на віддаленому хості.", - "settings.remoteInstances.page.section.mainTunnel": "Головний тунель", - "settings.remoteInstances.page.section.mainTunnelDescription": "Основна локальна кінцева точка для цього віддаленого інстанса.", + "settings.remoteInstances.page.section.actionsDescription": "Підключіться, перепідключіться, перегляньте журнали або видаліть це підключення.", + "settings.remoteInstances.page.section.remoteServer": "OpenChamber на віддаленій машині", + "settings.remoteInstances.page.section.remoteServerDescription": "Виберіть, як OpenChamber має працювати після SSH-підключення.", + "settings.remoteInstances.page.section.mainTunnel": "Локальний доступ", + "settings.remoteInstances.page.section.mainTunnelDescription": "Виберіть локальну адресу, через яку відкриватиметься цей віддалений сервер OpenChamber.", "settings.remoteInstances.page.section.authentication": "Аутентифікація", "settings.remoteInstances.page.section.authenticationDescription": "Додаткові облікові дані для SSH та віддаленого інтерфейсу користувача OpenChamber.", "settings.remoteInstances.page.section.portForwards": "Перенаправлення портів", - "settings.remoteInstances.page.section.portForwardsDescription": "Додаткове SSH-переадресування поза основним тунелем.", + "settings.remoteInstances.page.section.portForwardsDescription": "Додаткові порти, які можна зробити доступними через це SSH-підключення.", "settings.remoteInstances.page.field.sshCommand": "Команда SSH", "settings.remoteInstances.page.field.sshCommandPlaceholder": "ssh користувач@хост", "settings.remoteInstances.page.field.nickname": "Назва", - "settings.remoteInstances.page.field.nicknamePlaceholder": "Мій віддалений хост", + "settings.remoteInstances.page.field.nicknamePlaceholder": "Робочий ноутбук", "settings.remoteInstances.page.field.connectionTimeoutSeconds": "Тайм-аут підключення (секунди)", "settings.remoteInstances.page.field.installMethod": "Спосіб встановлення", - "settings.remoteInstances.page.field.installMethodHint": "Як інсталюється OpenChamber під час роботи в керованому режимі.", + "settings.remoteInstances.page.field.installMethodHint": "Як розмістити OpenChamber на віддаленій машині, коли цей застосунок запускає його для вас.", "settings.remoteInstances.page.field.selectInstallMethodPlaceholder": "Вибрати метод встановлення", "settings.remoteInstances.page.field.installMethodDownloadRelease": "Завантажити випуск", "settings.remoteInstances.page.field.installMethodUploadBundle": "Завантажити пакет", @@ -973,14 +1004,14 @@ export const settingsDict = { "settings.remoteInstances.page.field.sshPasswordPlaceholder": "Введіть пароль SSH", "settings.remoteInstances.page.field.uiPasswordOptional": "Пароль інтерфейсу користувача (необов'язково)", "settings.remoteInstances.page.field.uiPasswordPlaceholder": "Введіть пароль інтерфейсу", - "settings.remoteInstances.page.field.forwardTypeHint": "Виберіть локальне (-L), віддалене (-R) або динамічне (-D) переадресування.", + "settings.remoteInstances.page.field.forwardTypeHint": "Виберіть, який доступ до портів має надавати це SSH-підключення.", "settings.remoteInstances.page.field.typePlaceholder": "Тип", "settings.remoteInstances.page.forwardType.local": "Локальний (-L)", "settings.remoteInstances.page.forwardType.remote": "Віддалений (-R)", "settings.remoteInstances.page.forwardType.dynamic": "Динамічний (-D)", - "settings.remoteInstances.page.forwardTypeDescription.local": "Перенаправляти локальний трафік до віддаленого пункту призначення.", - "settings.remoteInstances.page.forwardTypeDescription.remote": "Відкрити віддалену кінцеву точку та переслати її назад на локальну машину.", - "settings.remoteInstances.page.forwardTypeDescription.dynamic": "Відкрити локальний SOCKS5-проксі через SSH.", + "settings.remoteInstances.page.forwardTypeDescription.local": "Відкрити локальний порт, який підключається до сервісу на віддаленій машині.", + "settings.remoteInstances.page.forwardTypeDescription.remote": "Відкрити порт на віддаленій машині, який підключається назад до вашого комп’ютера.", + "settings.remoteInstances.page.forwardTypeDescription.dynamic": "Відкрити локальний SOCKS-проксі через SSH-підключення.", "settings.remoteInstances.page.actions.create": "Створити", "settings.remoteInstances.page.actions.cancel": "Скасувати", "settings.remoteInstances.page.actions.connecting": "Підключення...", @@ -991,7 +1022,7 @@ export const settingsDict = { "settings.remoteInstances.page.actions.enableForwardAria": "Увімкнути пересилання", "settings.remoteInstances.page.actions.openLocal": "Відкрити локально", "settings.remoteInstances.page.actions.addForward": "Додати переадресацію", - "settings.remoteInstances.page.import.sectionTitle": "Імпортувати з конфігурації SSH", + "settings.remoteInstances.page.import.sectionTitle": "Збережені SSH-хости", "settings.remoteInstances.page.import.loading": "Завантаження хостів SSH...", "settings.remoteInstances.page.import.noneFound": "Не знайдено хостів SSH.", "settings.remoteInstances.page.import.noneAvailable": "Немає доступних для імпорту хостів SSH.", @@ -1006,7 +1037,7 @@ export const settingsDict = { "settings.remoteInstances.page.phase.resolvingConfiguration": "Визначення конфігурації", "settings.remoteInstances.page.phase.checkingAuth": "Перевірка автентифікації", "settings.remoteInstances.page.phase.establishingSsh": "Встановлення підключення SSH", - "settings.remoteInstances.page.phase.probingRemote": "Перевірка віддаленого хоста", + "settings.remoteInstances.page.phase.probingRemote": "Перевірка віддаленої машини", "settings.remoteInstances.page.phase.installingOpenChamber": "Встановлення OpenChamber", "settings.remoteInstances.page.phase.updatingOpenChamber": "Оновлення OpenChamber", "settings.remoteInstances.page.phase.detectingServer": "Виявлення сервера", @@ -1471,6 +1502,9 @@ export const settingsDict = { "settings.voice.page.preview.voiceLine": "Привіт! Я {voiceName}. Ось як я звучу.", "settings.voice.page.preview.customServerLine": "Привіт! Це попередній перегляд спеціального сервера TTS.", "settings.openchamber.visual.section.colorMode": "Режим теми", + "settings.openchamber.visual.section.mobileLayout": "Мобільний макет", + "settings.openchamber.visual.option.mobileLayout.default": "Стандартний", + "settings.openchamber.visual.option.mobileLayout.new": "Новий", "settings.openchamber.visual.section.localization": "Локалізація", "settings.openchamber.visual.section.spacingAndLayout": "Відступи й компонування", "settings.openchamber.visual.section.navigation": "Навігація", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index fecb02a2..4e937c92 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -27,6 +27,94 @@ export const dict: Record = { "layout.mainTab.files": "Файли", "layout.mainTab.terminal": "Термінал", "layout.mainTab.context": "Контекст", + "mobile.nav.aria": "Мобільна навігація", + "mobile.nav.changes": "Зміни", + "mobile.nav.settings": "Налаштування", + "mobile.surface.closeAria": "Закрити", + "mobile.header.openMenuAria": "Відкрити меню", + "mobile.menu.titleAria": "Інструменти робочого простору", + "mobile.menu.files": "Файли", + "mobile.menu.changes": "Зміни", + "mobile.menu.settings": "Налаштування", + "mobile.sessions.newChatCta": "Новий чат у {project}", + "mobile.sessions.dateGroup.today": "Сьогодні", + "mobile.sessions.dateGroup.yesterday": "Вчора", + "mobile.sessions.dateGroup.thisWeek": "Раніше цього тижня", + "mobile.sessions.dateGroup.older": "Давніше", + "mobile.sessions.section.worktrees": "Worktrees", + "mobile.sessions.section.otherProjects": "Інші проєкти", + "mobile.sessions.section.projects": "Проєкти", + "mobile.sessions.empty.noProjectsTitle": "Ще немає проєктів", + "mobile.sessions.empty.noProjectsDescription": "Додай проєкт, щоб почати спілкування з кодом.", + "mobile.sessions.empty.noSessionsTitle": "Ще немає сесій", + "mobile.sessions.empty.noSessionsDescription": "Почни перший чат, щоб побачити його тут.", + "mobile.sessions.empty.searchTitle": "Нічого не знайдено", + "mobile.sessions.empty.searchDescription": "Спробуй інший запит.", + "mobile.sessions.showArchived": "Показати архівовані ({count})", + "mobile.sessions.hideArchived": "Сховати архівовані", + "mobile.sessions.activeWorktreeAria": "Активний worktree", + "mobile.sessions.activeProjectAria": "Активний проєкт", + "mobile.sessions.startNewChat": "Почати новий чат", + "mobile.sessions.newChat": "Новий чат", + "mobile.sessions.editOrder": "Змінити порядок проєктів", + "mobile.sessions.doneEditing": "Готово", + "mobile.sessions.editOrderHint": "Перетягни ручку або скористайся стрілками, щоб змінити порядок проєктів. Натисни галочку щоб завершити.", + "mobile.sessions.dragHandleAria": "Перетягни {label}, щоб змінити порядок", + "mobile.sessions.moveUpAria": "Перемістити {label} вгору", + "mobile.sessions.moveDownAria": "Перемістити {label} вниз", + "mobile.sessions.removeProjectAria": "Видалити {label}", + "mobile.sessions.cancelRemoveProjectAria": "Скасувати видалення {label}", + "mobile.sessions.confirmRemoveProject": "Видалити", + "mobile.sessions.confirmRemoveProjectAria": "Підтвердити видалення {label}", + "mobile.sessions.toast.projectRemoved": "Видалено {label}", + "mobile.sessions.showMore": "Показати ще {count}", + "mobile.sessions.search.section.sessions": "Сесії", + "mobile.sessions.search.section.archived": "Архів", + "mobile.sessions.search.section.projects": "Проєкти", + "mobile.sessions.clearSearchAria": "Очистити пошук", + "mobile.header.noProject": "Виберіть проєкт", + "mobile.header.activeSession": "Активна сесія", + "mobile.header.noSession": "Немає активної сесії", + "mobile.sessions.openSheetAria": "Відкрити сесії та проєкти", + "mobile.sessions.closeSheetAria": "Закрити сесії та проєкти", + "mobile.sessions.sheet.title": "Сесії", + "mobile.sessions.sheet.description": "Перемикайте проєкти, відкривайте сесії або починайте новий чат.", + "mobile.sessions.search.placeholder": "Пошук сесій", + "mobile.sessions.empty": "Сесій не знайдено.", + "mobile.sessions.unassignedProject": "Інші сесії", + "mobile.sessions.newSessionAria": "Почати нову сесію в цьому проєкті", + "mobile.sessions.untitled": "Сесія без назви", + "mobile.sessions.project.sessionsSingle": "1 сесія", + "mobile.sessions.project.sessionsPlural": "Сесій: {count}", + "mobile.files.refreshAria": "Оновити файли", + "mobile.files.backToParentAria": "Назад до {name}", + "mobile.files.rootDirectory": "Файли проєкту", + "mobile.files.search.placeholder": "Шукати файли", + "mobile.files.search.empty": "Файлів не знайдено.", + "mobile.files.parentDirectory": "Батьківська тека", + "mobile.files.empty.noDirectory": "Виберіть проєкт, щоб переглядати файли.", + "mobile.files.empty.directory": "Ця тека порожня.", + "mobile.files.error.listFailed": "Не вдалося завантажити файли", + "mobile.files.error.readUnavailable": "Попередній перегляд файлу недоступний у цьому середовищі.", + "mobile.files.file.truncated": "Попередній перегляд файлу обрізано для мобільного режиму.", + "mobile.files.copyPathAria": "Скопіювати шлях до файлу", + "mobile.files.copyContent": "Скопіювати вміст", + "mobile.files.copyContentAria": "Скопіювати вміст файлу", + "mobile.files.toast.pathCopied": "Шлях скопійовано", + "mobile.files.toast.contentCopied": "Вміст скопійовано", + "mobile.files.toast.copyFailed": "Не вдалося скопіювати", + "mobile.changes.placeholder.title": "Зміни", + "mobile.changes.placeholder.description": "Тут буде review робочого дерева, sync і commit actions.", + "mobile.changes.branchLabel": "Гілка: {branch}", + "mobile.changes.noRemote": "Немає доступного remote", + "mobile.changes.cleanDescription": "У цьому workspace немає змінених файлів.", + "mobile.changes.diffDetail.subtitle": "Read-only diff", + "mobile.changes.diffDetail.loadFailed": "Не вдалося завантажити diff", + "mobile.changes.diffDetail.missingTitle": "Файл більше не змінений", + "mobile.changes.diffDetail.missingDescription": "Поверніться до Змін і оновіть список.", + "mobile.changes.diffDetail.imageUnavailable": "Image diffs поки недоступні в мобільних Змінах.", + "mobile.settings.placeholder.title": "Налаштування", + "mobile.settings.placeholder.description": "Тут будуть сфокусовані мобільні налаштування підключення та апки.", "layout.rightSidebar.git": "Git", "layout.rightSidebar.files": "Файли", "layout.rightSidebar.context": "Контекст", @@ -1125,6 +1213,12 @@ export const dict: Record = { "header.services.refreshRateLimitsAria": "Оновити ліміти запитів", "header.services.noRateLimits": "Ліміти запитів недоступні.", "header.services.noRateLimitsReported": "Ліміти запитів не надходять.", + "header.services.remoteUpdate.title": "Оновлення віддаленого інстанса", + "header.services.remoteUpdate.checking": "Шукаємо оновлення...", + "header.services.remoteUpdate.upToDate": "Цей інстанс уже оновлений.", + "header.services.remoteUpdate.available": "Для цього інстанса доступна версія {version}.", + "header.services.remoteUpdate.error": "Не вдалося перевірити оновлення віддаленого інстанса", + "header.services.remoteUpdate.actions.open": "Оновити", "header.services.used": "Використано", "header.services.remaining": "Залишилося", "header.services.shutdownDev": "Зупинити OpenChamber", @@ -2035,6 +2129,9 @@ export const dict: Record = { "desktopHostSwitcher.header.currentDefaultColon": "Поточне значення за умовчанням:", "desktopHostSwitcher.status.connected": "Підключено", "desktopHostSwitcher.status.authRequired": "Потрібна авторизація", + "desktopHostSwitcher.status.checking": "Перевірка", + "desktopHostSwitcher.status.updateRecommended": "Рекомендовано оновити", + "desktopHostSwitcher.status.incompatible": "Несумісний", "desktopHostSwitcher.status.wrongService": "Неправильний сервіс", "desktopHostSwitcher.status.unreachable": "Недосяжний", "desktopHostSwitcher.status.unknown": "Невідомий", @@ -2221,6 +2318,8 @@ export const dict: Record = { "onboarding.remoteConnection.actions.chooseDifferentServer": "Вибрати інший сервер", "onboarding.remoteConnection.actions.useLocalInstead": "Натомість використовувати Local", "onboarding.remoteConnection.probe.authMessage": "Сервер вимагає автентифікації. Ви все ще можете підключитися, але, можливо, знадобиться надати облікові дані.", + "onboarding.remoteConnection.probe.updateRecommendedMessage": "Цей інстанс працює з іншою версією OpenChamber. Можна підключитися, але оновіть обидва застосунки, якщо щось не працюватиме.", + "onboarding.remoteConnection.probe.incompatibleMessage": "Сервер працює з OpenChamber, але несумісний із цією версією застосунку. Оновіть OpenChamber на сервері та повторіть спробу.", "onboarding.remoteConnection.probe.wrongServiceMessage": "Сервер відповів, але не працює OpenChamber. Перевірте, чи адреса вказує на сервер OpenChamber.", "onboarding.remoteConnection.probe.unreachableMessage": "Сервер недоступний. Перевірте підключення до мережі та адресу сервера.", "onboarding.desktopRecovery.localUnavailable.title": "OpenCode Local недоступний", @@ -2234,6 +2333,8 @@ export const dict: Record = { "onboarding.desktopRecovery.remoteUnreachable.retry": "Повторити підключення", "onboarding.desktopRecovery.incompatibleServer.title": "Несумісний сервер", "onboarding.desktopRecovery.incompatibleServer.description": "На сервері \"{host}\" не запущено OpenChamber. Перевірте, чи адреса вказує на сервер OpenChamber.", + "onboarding.desktopRecovery.remoteIncompatible.title": "Потрібне оновлення сервера", + "onboarding.desktopRecovery.remoteIncompatible.description": "Сервер OpenChamber на \"{host}\" несумісний із цією версією застосунку. Оновіть OpenChamber на сервері та повторіть спробу.", "onboarding.desktopRecovery.common.useLocal": "Використовувати Local", "onboarding.desktopRecovery.common.useRemote": "Використовувати Remote", "onboarding.desktopRecovery.actions.retrying": "Повторна спроба…", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index fab6f8f7..711e11c4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -198,19 +198,50 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.sessionFusion': '融合', 'settings.remoteInstances.sidebar.title': '远程实例', 'settings.remoteInstances.sidebar.total': '总计 {count}', - 'settings.remoteInstances.sidebar.newSshInstanceName': '新建 SSH 实例', - 'settings.remoteInstances.sidebar.actions.addSshInstance': '添加 SSH 实例', + 'settings.remoteInstances.sidebar.newSshInstanceName': '新的 SSH 连接', + 'settings.remoteInstances.sidebar.actions.addSshInstance': '添加 SSH 连接', 'settings.remoteInstances.sidebar.actions.connect': '连接', 'settings.remoteInstances.sidebar.actions.disconnect': '断开连接', 'settings.remoteInstances.sidebar.actions.retry': '重试', 'settings.remoteInstances.sidebar.actions.remove': '移除', 'settings.remoteInstances.sidebar.confirm.localPortInUseRetry': '本地端口已被占用。是否选择一个随机空闲本地端口并重试?', - 'settings.remoteInstances.sidebar.toast.createFailed': '创建 SSH 实例失败', + 'settings.remoteInstances.sidebar.toast.createFailed': '无法创建 SSH 连接', 'settings.remoteInstances.sidebar.toast.retriedWithRandomPort': '已使用随机本地端口重试', 'settings.remoteInstances.sidebar.toast.connectFailed': '连接实例失败', 'settings.remoteInstances.sidebar.toast.disconnectFailed': '断开实例连接失败', 'settings.remoteInstances.sidebar.toast.retryFailed': '重试连接失败', 'settings.remoteInstances.sidebar.toast.removeFailed': '移除实例失败', + 'settings.remoteInstances.direct.sidebarTitle': '服务器链接', + 'settings.remoteInstances.direct.sidebarDescription': '使用链接或令牌连接', + 'settings.remoteInstances.direct.title': '其他 OpenChamber 服务器', + 'settings.remoteInstances.direct.description': '通过 URL 添加另一个 OpenChamber 服务器。适用于服务器已在运行且你拥有连接令牌的情况。', + 'settings.remoteInstances.direct.field.labelPlaceholder': '标签(可选)', + 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', + 'settings.remoteInstances.direct.field.tokenPlaceholder': '连接令牌(受信任的本地服务器可选)', + 'settings.remoteInstances.direct.note': '连接令牌会保存在此设备上,并且只在此应用连接到该服务器时使用。', + 'settings.remoteInstances.direct.actions.add': '添加服务器', + 'settings.remoteInstances.direct.import.description': '粘贴来自另一个 OpenChamber 服务器的连接链接。', + 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', + 'settings.remoteInstances.direct.import.action': '导入链接', + 'settings.remoteInstances.direct.error.invalidConnectLink': '无效的 OpenChamber 连接链接。', + 'settings.remoteInstances.direct.state.loading': '正在加载服务器...', + 'settings.remoteInstances.direct.state.empty': '尚未添加其他服务器。', + 'settings.remoteInstances.clientAuth.title': '连接到此服务器', + 'settings.remoteInstances.clientAuth.description': '创建安全链接或令牌,让 OpenChamber Desktop 可以连接到此服务器。', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': '设备名称(可选)', + 'settings.remoteInstances.clientAuth.actions.create': '创建令牌', + 'settings.remoteInstances.clientAuth.actions.pair': '创建链接', + 'settings.remoteInstances.clientAuth.actions.revoke': '撤销', + 'settings.remoteInstances.clientAuth.actions.clearRevoked': '清除已撤销', + 'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code', + 'settings.remoteInstances.clientAuth.pairingUrl': '连接链接', + 'settings.remoteInstances.clientAuth.createdToken': '请立即复制此令牌。出于安全考虑,它不会再次显示。', + 'settings.remoteInstances.clientAuth.state.loading': '正在加载令牌...', + 'settings.remoteInstances.clientAuth.state.empty': '尚无已连接设备。', + 'settings.remoteInstances.clientAuth.state.revoked': '已撤销', + 'settings.remoteInstances.clientAuth.state.thisDevice': '此设备', + 'settings.remoteInstances.clientAuth.lastUsed': '上次使用 {date}', + 'settings.remoteInstances.clientAuth.neverUsed': '从未使用', 'settings.remoteInstances.sidebar.phase.ready': '就绪', 'settings.remoteInstances.sidebar.phase.error': '错误', 'settings.remoteInstances.sidebar.phase.reconnect': '重连', @@ -221,20 +252,20 @@ export const settingsDict = { 'settings.remoteInstances.sidebar.phase.connecting': '连接中', 'settings.remoteInstances.sidebar.phase.idle': '空闲', 'settings.remoteInstances.page.section.instance': '实例', - 'settings.remoteInstances.page.section.instanceDescription': '核心 SSH 设置。', + 'settings.remoteInstances.page.section.instanceDescription': '为此连接选择 SSH 命令和显示名称。', 'settings.remoteInstances.page.field.mode': '模式', - 'settings.remoteInstances.page.field.modeHint': 'Managed 模式会在远端安装/更新并启动 OpenChamber。External 模式假设它已在运行。', + 'settings.remoteInstances.page.field.modeHint': '选择是由 OpenChamber 为你启动服务器,还是连接到已在运行的服务器。', 'settings.remoteInstances.page.field.modePlaceholder': '选择模式', - 'settings.remoteInstances.page.field.modeManaged': 'Managed(自动启动)', - 'settings.remoteInstances.page.field.modeExternal': 'External(已在运行)', + 'settings.remoteInstances.page.field.modeManaged': '帮我启动', + 'settings.remoteInstances.page.field.modeExternal': '已在运行', 'settings.remoteInstances.page.field.preferredRemotePort': '首选远程端口', - 'settings.remoteInstances.page.field.preferredRemotePortHint': 'OpenChamber 在远程主机使用的端口。留空则由运行时自动选择。', + 'settings.remoteInstances.page.field.preferredRemotePortHint': '远程机器上使用的端口。留空则自动选择。', 'settings.remoteInstances.page.field.keepServerRunning': '保持服务运行', - 'settings.remoteInstances.page.field.keepServerRunningHint': '启用后,断开连接时会保留远端 OpenChamber 守护进程。', + 'settings.remoteInstances.page.field.keepServerRunningHint': '断开连接后仍让 OpenChamber 在远程机器上运行。', 'settings.remoteInstances.page.field.bindHost': '绑定主机', - 'settings.remoteInstances.page.field.bindHostHint': '主本地访问地址使用的网络接口。使用 127.0.0.1/localhost 可仅本机访问。', + 'settings.remoteInstances.page.field.bindHostHint': '本地连接监听的地址。除非需要局域网访问,否则请使用 127.0.0.1 或 localhost。', 'settings.remoteInstances.page.field.preferredLocalPort': '首选本地端口', - 'settings.remoteInstances.page.field.preferredLocalPortHint': '主 OpenChamber 隧道的首选本地端口。留空自动选择。', + 'settings.remoteInstances.page.field.preferredLocalPortHint': '为此连接打开的本地端口。留空则自动选择。', 'settings.remoteInstances.page.field.forwardType': '转发类型', 'settings.remoteInstances.page.field.localHostPlaceholder': '127.0.0.1', 'settings.remoteInstances.page.field.remoteHostPlaceholder': '127.0.0.1', @@ -243,7 +274,7 @@ export const settingsDict = { 'settings.remoteInstances.page.preview.localSocks5': '(本地 SOCKS5)', 'settings.remoteInstances.page.preview.local': '(本地)', 'settings.remoteInstances.page.preview.remote': '(远程)', - 'settings.remoteInstances.page.toast.openLocalEndpointFailed': '打开本地端点失败', + 'settings.remoteInstances.page.toast.openLocalEndpointFailed': '无法打开本地地址', 'settings.remoteInstances.page.toast.localUrlCopied': '本地 URL 已复制', 'settings.remoteInstances.page.actions.copyLocalUrl': '复制本地 URL', 'settings.remoteInstances.page.actions.open': '打开', @@ -945,26 +976,26 @@ export const settingsDict = { 'settings.usage.pace.waitSeparator': ' · 等待 ', 'settings.usage.pace.predictionLabel': '预测:', 'settings.remoteInstances.page.title': '远程实例', - 'settings.remoteInstances.page.description': '配置 SSH 连接、远程服务和端口转发设置。', + 'settings.remoteInstances.page.description': '通过 SSH 连接到另一台机器,并在那里打开 OpenChamber。', 'settings.remoteInstances.page.empty.selectInstance': '选择一个实例以查看和编辑其设置。', 'settings.remoteInstances.page.empty.noExtraForwards': '尚未配置额外端口转发。', 'settings.remoteInstances.page.section.actions': '操作', - 'settings.remoteInstances.page.section.actionsDescription': '连接、重连、查看日志或移除此实例。', - 'settings.remoteInstances.page.section.remoteServer': '远程服务', - 'settings.remoteInstances.page.section.remoteServerDescription': 'OpenChamber 在远端主机上的管理与启动方式。', - 'settings.remoteInstances.page.section.mainTunnel': '主隧道', - 'settings.remoteInstances.page.section.mainTunnelDescription': '该远程实例的主本地访问端点。', + 'settings.remoteInstances.page.section.actionsDescription': '连接、重新连接、查看日志或移除此连接。', + 'settings.remoteInstances.page.section.remoteServer': '远程机器上的 OpenChamber', + 'settings.remoteInstances.page.section.remoteServerDescription': '选择 SSH 连接后 OpenChamber 的运行方式。', + 'settings.remoteInstances.page.section.mainTunnel': '本地访问', + 'settings.remoteInstances.page.section.mainTunnelDescription': '选择用于打开此远程 OpenChamber 服务器的本地地址。', 'settings.remoteInstances.page.section.authentication': '认证', 'settings.remoteInstances.page.section.authenticationDescription': 'SSH 和远程 OpenChamber UI 的可选凭据。', 'settings.remoteInstances.page.section.portForwards': '端口转发', - 'settings.remoteInstances.page.section.portForwardsDescription': '除主隧道外的额外 SSH 转发。', + 'settings.remoteInstances.page.section.portForwardsDescription': '可选的额外端口,可通过此 SSH 连接访问。', 'settings.remoteInstances.page.field.sshCommand': 'SSH 命令', 'settings.remoteInstances.page.field.sshCommandPlaceholder': 'ssh user@host', 'settings.remoteInstances.page.field.nickname': '昵称', - 'settings.remoteInstances.page.field.nicknamePlaceholder': '我的远程主机', + 'settings.remoteInstances.page.field.nicknamePlaceholder': '工作笔记本', 'settings.remoteInstances.page.field.connectionTimeoutSeconds': '连接超时(秒)', 'settings.remoteInstances.page.field.installMethod': '安装方式', - 'settings.remoteInstances.page.field.installMethodHint': '在 managed 模式下 OpenChamber 的安装方式。', + 'settings.remoteInstances.page.field.installMethodHint': '当此应用为你启动 OpenChamber 时,如何将它放到远程机器上。', 'settings.remoteInstances.page.field.selectInstallMethodPlaceholder': '选择安装方式', 'settings.remoteInstances.page.field.installMethodDownloadRelease': '下载发布版本', 'settings.remoteInstances.page.field.installMethodUploadBundle': '上传安装包', @@ -973,14 +1004,14 @@ export const settingsDict = { 'settings.remoteInstances.page.field.sshPasswordPlaceholder': '输入 SSH 密码', 'settings.remoteInstances.page.field.uiPasswordOptional': 'UI 密码(可选)', 'settings.remoteInstances.page.field.uiPasswordPlaceholder': '输入 UI 密码', - 'settings.remoteInstances.page.field.forwardTypeHint': '选择本地(-L)、远程(-R)或动态(-D)转发。', + 'settings.remoteInstances.page.field.forwardTypeHint': '选择此 SSH 连接应提供哪种端口访问。', 'settings.remoteInstances.page.field.typePlaceholder': '类型', 'settings.remoteInstances.page.forwardType.local': '本地(-L)', 'settings.remoteInstances.page.forwardType.remote': '远程(-R)', 'settings.remoteInstances.page.forwardType.dynamic': '动态(-D)', - 'settings.remoteInstances.page.forwardTypeDescription.local': '将本地流量转发到远程目标。', - 'settings.remoteInstances.page.forwardTypeDescription.remote': '暴露远端端点并回传到本地机器。', - 'settings.remoteInstances.page.forwardTypeDescription.dynamic': '通过 SSH 暴露本地 SOCKS5 代理。', + 'settings.remoteInstances.page.forwardTypeDescription.local': '打开一个本地端口,连接到远程机器上的服务。', + 'settings.remoteInstances.page.forwardTypeDescription.remote': '在远程机器上打开一个端口,并连接回你的电脑。', + 'settings.remoteInstances.page.forwardTypeDescription.dynamic': '通过 SSH 连接打开本地 SOCKS 代理。', 'settings.remoteInstances.page.actions.create': '创建', 'settings.remoteInstances.page.actions.cancel': '取消', 'settings.remoteInstances.page.actions.connecting': '连接中...', @@ -991,7 +1022,7 @@ export const settingsDict = { 'settings.remoteInstances.page.actions.enableForwardAria': '启用转发', 'settings.remoteInstances.page.actions.openLocal': '打开本地', 'settings.remoteInstances.page.actions.addForward': '添加转发', - 'settings.remoteInstances.page.import.sectionTitle': '从 SSH 配置导入', + 'settings.remoteInstances.page.import.sectionTitle': '已保存的 SSH 主机', 'settings.remoteInstances.page.import.loading': '正在加载 SSH 主机...', 'settings.remoteInstances.page.import.noneFound': '未找到 SSH 主机。', 'settings.remoteInstances.page.import.noneAvailable': '没有可导入的 SSH 主机。', @@ -1006,7 +1037,7 @@ export const settingsDict = { 'settings.remoteInstances.page.phase.resolvingConfiguration': '正在解析配置', 'settings.remoteInstances.page.phase.checkingAuth': '正在检查认证', 'settings.remoteInstances.page.phase.establishingSsh': '正在建立 SSH 连接', - 'settings.remoteInstances.page.phase.probingRemote': '正在探测远程主机', + 'settings.remoteInstances.page.phase.probingRemote': '正在检查远程机器', 'settings.remoteInstances.page.phase.installingOpenChamber': '正在安装 OpenChamber', 'settings.remoteInstances.page.phase.updatingOpenChamber': '正在更新 OpenChamber', 'settings.remoteInstances.page.phase.detectingServer': '正在检测服务', @@ -1471,6 +1502,9 @@ export const settingsDict = { 'settings.voice.page.preview.voiceLine': '你好!我是 {voiceName}。这是我的声音效果。', 'settings.voice.page.preview.customServerLine': '你好!这是自定义 TTS 服务器的预览。', 'settings.openchamber.visual.section.colorMode': '颜色模式', + 'settings.openchamber.visual.section.mobileLayout': '移动端布局', + 'settings.openchamber.visual.option.mobileLayout.default': '默认', + 'settings.openchamber.visual.option.mobileLayout.new': '新版', 'settings.openchamber.visual.section.localization': '本地化', 'settings.openchamber.visual.section.spacingAndLayout': '间距与布局', 'settings.openchamber.visual.section.navigation': '导航', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 8a8b8d1b..d4283953 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -27,6 +27,94 @@ export const dict: Record = { 'layout.mainTab.files': '文件', 'layout.mainTab.terminal': '终端', 'layout.mainTab.context': '上下文', + 'mobile.nav.aria': '移动导航', + 'mobile.nav.changes': '更改', + 'mobile.nav.settings': '设置', + 'mobile.surface.closeAria': '关闭', + 'mobile.header.openMenuAria': '打开菜单', + 'mobile.menu.titleAria': '工作区工具', + 'mobile.menu.files': '文件', + 'mobile.menu.changes': '更改', + 'mobile.menu.settings': '设置', + 'mobile.sessions.newChatCta': '在 {project} 中新建会话', + 'mobile.sessions.dateGroup.today': '今天', + 'mobile.sessions.dateGroup.yesterday': '昨天', + 'mobile.sessions.dateGroup.thisWeek': '本周早些时候', + 'mobile.sessions.dateGroup.older': '更早', + 'mobile.sessions.section.worktrees': '工作树', + 'mobile.sessions.section.otherProjects': '切换项目', + 'mobile.sessions.section.projects': '项目', + 'mobile.sessions.empty.noProjectsTitle': '暂无项目', + 'mobile.sessions.empty.noProjectsDescription': '添加项目以开始与代码对话。', + 'mobile.sessions.empty.noSessionsTitle': '暂无会话', + 'mobile.sessions.empty.noSessionsDescription': '开始第一次会话即可在此显示。', + 'mobile.sessions.empty.searchTitle': '无结果', + 'mobile.sessions.empty.searchDescription': '请尝试其他搜索词。', + 'mobile.sessions.showArchived': '显示已归档 ({count})', + 'mobile.sessions.hideArchived': '隐藏已归档', + 'mobile.sessions.activeWorktreeAria': '活动工作树', + 'mobile.sessions.activeProjectAria': '活动项目', + 'mobile.sessions.startNewChat': '开始新会话', + 'mobile.sessions.newChat': '新会话', + 'mobile.sessions.editOrder': '重新排序项目', + 'mobile.sessions.doneEditing': '完成', + 'mobile.sessions.editOrderHint': '拖动手柄或使用箭头重新排序。点击对勾以完成。', + 'mobile.sessions.dragHandleAria': '拖动 {label} 以重新排序', + 'mobile.sessions.moveUpAria': '将 {label} 上移', + 'mobile.sessions.moveDownAria': '将 {label} 下移', + 'mobile.sessions.removeProjectAria': '移除 {label}', + 'mobile.sessions.cancelRemoveProjectAria': '取消移除 {label}', + 'mobile.sessions.confirmRemoveProject': '删除', + 'mobile.sessions.confirmRemoveProjectAria': '确认移除 {label}', + 'mobile.sessions.toast.projectRemoved': '已移除 {label}', + 'mobile.sessions.showMore': '再显示 {count} 个', + 'mobile.sessions.search.section.sessions': '会话', + 'mobile.sessions.search.section.archived': '已归档', + 'mobile.sessions.search.section.projects': '项目', + 'mobile.sessions.clearSearchAria': '清除搜索', + 'mobile.header.noProject': '选择项目', + 'mobile.header.activeSession': '活动会话', + 'mobile.header.noSession': '无活动会话', + 'mobile.sessions.openSheetAria': '打开会话和项目', + 'mobile.sessions.closeSheetAria': '关闭会话和项目', + 'mobile.sessions.sheet.title': '会话', + 'mobile.sessions.sheet.description': '切换项目、打开会话或开始新聊天。', + 'mobile.sessions.search.placeholder': '搜索会话', + 'mobile.sessions.empty': '未找到会话。', + 'mobile.sessions.unassignedProject': '其他会话', + 'mobile.sessions.newSessionAria': '在此项目中开始新会话', + 'mobile.sessions.untitled': '未命名会话', + 'mobile.sessions.project.sessionsSingle': '1 个会话', + 'mobile.sessions.project.sessionsPlural': '{count} 个会话', + 'mobile.files.refreshAria': '刷新文件', + 'mobile.files.backToParentAria': '返回 {name}', + 'mobile.files.rootDirectory': '项目文件', + 'mobile.files.search.placeholder': '搜索文件', + 'mobile.files.search.empty': '未找到文件。', + 'mobile.files.parentDirectory': '上级目录', + 'mobile.files.empty.noDirectory': '选择一个项目以浏览文件。', + 'mobile.files.empty.directory': '此目录为空。', + 'mobile.files.error.listFailed': '加载文件失败', + 'mobile.files.error.readUnavailable': '此运行环境不支持文件预览。', + 'mobile.files.file.truncated': '移动端文件预览已截断。', + 'mobile.files.copyPathAria': '复制文件路径', + 'mobile.files.copyContent': '复制内容', + 'mobile.files.copyContentAria': '复制文件内容', + 'mobile.files.toast.pathCopied': '路径已复制', + 'mobile.files.toast.contentCopied': '内容已复制', + 'mobile.files.toast.copyFailed': '复制失败', + 'mobile.changes.placeholder.title': '更改', + 'mobile.changes.placeholder.description': '工作区更改 review、sync 和 commit 操作将在这里显示。', + 'mobile.changes.branchLabel': '分支:{branch}', + 'mobile.changes.noRemote': '没有可用的 remote', + 'mobile.changes.cleanDescription': '此 workspace 中没有已更改文件。', + 'mobile.changes.diffDetail.subtitle': '只读 diff', + 'mobile.changes.diffDetail.loadFailed': '加载 diff 失败', + 'mobile.changes.diffDetail.missingTitle': '文件不再有更改', + 'mobile.changes.diffDetail.missingDescription': '返回“更改”并刷新列表。', + 'mobile.changes.diffDetail.imageUnavailable': '移动“更改”暂不支持图片 diff。', + 'mobile.settings.placeholder.title': '设置', + 'mobile.settings.placeholder.description': '移动连接和应用设置将在这里显示。', 'layout.rightSidebar.git': 'Git', 'layout.rightSidebar.files': '文件', 'layout.rightSidebar.context': '上下文', @@ -1125,6 +1213,12 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': '刷新速率限制', 'header.services.noRateLimits': '没有可用的速率限制。', 'header.services.noRateLimitsReported': '未上报速率限制。', + 'header.services.remoteUpdate.title': '远程实例更新', + 'header.services.remoteUpdate.checking': '正在检查更新...', + 'header.services.remoteUpdate.upToDate': '此实例已是最新。', + 'header.services.remoteUpdate.available': '此实例可更新到版本 {version}。', + 'header.services.remoteUpdate.error': '无法检查远程实例更新', + 'header.services.remoteUpdate.actions.open': '更新', 'header.services.used': '已用', 'header.services.remaining': '剩余', 'header.services.shutdownDev': '停止 OpenChamber', @@ -2035,6 +2129,9 @@ export const dict: Record = { 'desktopHostSwitcher.header.currentDefaultColon': '当前默认:', 'desktopHostSwitcher.status.connected': '已连接', 'desktopHostSwitcher.status.authRequired': '需要认证', + 'desktopHostSwitcher.status.checking': '检查中', + 'desktopHostSwitcher.status.updateRecommended': '建议更新', + 'desktopHostSwitcher.status.incompatible': '不兼容', 'desktopHostSwitcher.status.wrongService': '服务不匹配', 'desktopHostSwitcher.status.unreachable': '不可达', 'desktopHostSwitcher.status.unknown': '未知', @@ -2221,6 +2318,8 @@ export const dict: Record = { 'onboarding.remoteConnection.actions.chooseDifferentServer': '选择其他服务器', 'onboarding.remoteConnection.actions.useLocalInstead': '改用本地', 'onboarding.remoteConnection.probe.authMessage': '服务器需要身份验证。你仍可连接,但可能需要提供凭据。', + 'onboarding.remoteConnection.probe.updateRecommendedMessage': '此实例正在运行不同版本的 OpenChamber。你可以连接;如果功能异常,请更新两端应用。', + 'onboarding.remoteConnection.probe.incompatibleMessage': '服务器正在运行 OpenChamber,但与此应用版本不兼容。请更新服务器上的 OpenChamber 后重试。', 'onboarding.remoteConnection.probe.wrongServiceMessage': '服务器有响应,但未运行 OpenChamber。请确认地址是否指向 OpenChamber 服务器。', 'onboarding.remoteConnection.probe.unreachableMessage': '服务器不可达。请检查网络连接并确认服务器地址。', 'onboarding.desktopRecovery.localUnavailable.title': '本地 OpenCode 不可用', @@ -2234,6 +2333,8 @@ export const dict: Record = { 'onboarding.desktopRecovery.remoteUnreachable.retry': '重试连接', 'onboarding.desktopRecovery.incompatibleServer.title': '服务器不兼容', 'onboarding.desktopRecovery.incompatibleServer.description': '“{host}” 上的服务器未运行 OpenChamber。请确认地址是否指向 OpenChamber 服务器。', + 'onboarding.desktopRecovery.remoteIncompatible.title': '需要更新服务器', + 'onboarding.desktopRecovery.remoteIncompatible.description': '“{host}” 上的 OpenChamber 服务器与此应用版本不兼容。请更新服务器上的 OpenChamber 后重试。', 'onboarding.desktopRecovery.common.useLocal': '使用本地', 'onboarding.desktopRecovery.common.useRemote': '使用远程', 'onboarding.desktopRecovery.actions.retrying': '重试中…', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 5e54a72d..84ac49e0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -217,6 +217,37 @@ 'settings.remoteInstances.sidebar.phase.starting': '啟動中', 'settings.remoteInstances.sidebar.phase.connecting': '連線中', 'settings.remoteInstances.sidebar.phase.idle': '可用', + 'settings.remoteInstances.direct.sidebarTitle': '直接連線', + 'settings.remoteInstances.direct.sidebarDescription': '連線到已在執行的 OpenChamber 伺服器。', + 'settings.remoteInstances.direct.title': '直接遠端執行個體', + 'settings.remoteInstances.direct.description': '儲存可從桌面切換器使用的遠端伺服器 URL。', + 'settings.remoteInstances.direct.field.labelPlaceholder': '我的遠端伺服器', + 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://openchamber.example.com', + 'settings.remoteInstances.direct.field.tokenPlaceholder': '用戶端 token(可選)', + 'settings.remoteInstances.direct.note': '直接連線假設遠端伺服器已在執行並可從此裝置存取。', + 'settings.remoteInstances.direct.actions.add': '新增直接連線', + 'settings.remoteInstances.direct.import.description': '貼上 connect-url 輸出或 openchamber://connect 連結來匯入。', + 'settings.remoteInstances.direct.import.placeholder': '貼上連線連結', + 'settings.remoteInstances.direct.import.action': '匯入連線', + 'settings.remoteInstances.direct.error.invalidConnectLink': '連線連結無效', + 'settings.remoteInstances.direct.state.loading': '正在載入直接連線...', + 'settings.remoteInstances.direct.state.empty': '尚無直接連線。', + 'settings.remoteInstances.clientAuth.title': '用戶端存取 token', + 'settings.remoteInstances.clientAuth.description': '建立與管理可讓桌面或遠端用戶端連線的 token。', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': '裝置或用戶端名稱', + 'settings.remoteInstances.clientAuth.actions.create': '建立 token', + 'settings.remoteInstances.clientAuth.actions.pair': '配對裝置', + 'settings.remoteInstances.clientAuth.actions.revoke': '撤銷', + 'settings.remoteInstances.clientAuth.actions.clearRevoked': '清除已撤銷', + 'settings.remoteInstances.clientAuth.qrAlt': '配對 QR code', + 'settings.remoteInstances.clientAuth.pairingUrl': '配對 URL', + 'settings.remoteInstances.clientAuth.createdToken': '已建立 token', + 'settings.remoteInstances.clientAuth.state.loading': '正在載入用戶端 token...', + 'settings.remoteInstances.clientAuth.state.empty': '尚無用戶端 token。', + 'settings.remoteInstances.clientAuth.state.revoked': '已撤銷', + 'settings.remoteInstances.clientAuth.state.thisDevice': '此裝置', + 'settings.remoteInstances.clientAuth.lastUsed': '上次使用:{date}', + 'settings.remoteInstances.clientAuth.neverUsed': '從未使用', 'settings.remoteInstances.page.section.instance': '執行個體', 'settings.remoteInstances.page.section.instanceDescription': '核心 SSH 設定。', 'settings.remoteInstances.page.field.mode': '模式', @@ -1394,6 +1425,7 @@ 'settings.openchamber.visual.section.colorMode': '顏色模式', 'settings.openchamber.visual.section.localization': '在地化', 'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局', + 'settings.openchamber.visual.section.mobileLayout': '行動版版面', 'settings.openchamber.visual.section.navigation': '導覽', 'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式', 'settings.openchamber.visual.section.chatRenderModeAria': '聊天渲染模式', @@ -1437,6 +1469,8 @@ 'settings.openchamber.visual.field.mobileKeyboardModeAria': '行動裝置鍵盤行為', 'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': '選擇鍵盤行為', 'settings.openchamber.visual.actions.resetMobileKeyboardModeAria': '重設行動裝置鍵盤行為', + 'settings.openchamber.visual.option.mobileLayout.default': '預設', + 'settings.openchamber.visual.option.mobileLayout.new': '新版', 'settings.openchamber.visual.field.interfaceFontSize': '介面字體大小', 'settings.openchamber.visual.field.interfaceFont': '介面字體', 'settings.openchamber.visual.field.selectInterfaceFontAria': '選擇介面字體', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 221a6474..d6c4f2e4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -27,6 +27,107 @@ export const dict: Record = { 'layout.mainTab.files': '檔案', 'layout.mainTab.terminal': '終端機', 'layout.mainTab.context': '上下文', + 'mobile.nav.aria': '行動導覽', + 'mobile.nav.changes': '變更', + 'mobile.nav.settings': '設定', + 'mobile.surface.closeAria': '關閉', + 'mobile.header.openMenuAria': '開啟選單', + 'mobile.menu.titleAria': '工作區工具', + 'mobile.menu.files': '檔案', + 'mobile.menu.changes': '變更', + 'mobile.menu.settings': '設定', + 'mobile.sessions.newChatCta': '在 {project} 中新增聊天', + 'mobile.sessions.dateGroup.today': '今天', + 'mobile.sessions.dateGroup.yesterday': '昨天', + 'mobile.sessions.dateGroup.thisWeek': '本週稍早', + 'mobile.sessions.dateGroup.older': '更早', + 'mobile.sessions.section.worktrees': '工作樹', + 'mobile.sessions.section.otherProjects': '切換專案', + 'mobile.sessions.section.projects': '專案', + 'mobile.sessions.empty.noProjectsTitle': '尚無專案', + 'mobile.sessions.empty.noProjectsDescription': '新增專案即可開始與程式碼聊天。', + 'mobile.sessions.empty.noSessionsTitle': '尚無會話', + 'mobile.sessions.empty.noSessionsDescription': '開始第一個聊天後會顯示在這裡。', + 'mobile.sessions.empty.searchTitle': '沒有結果', + 'mobile.sessions.empty.searchDescription': '請嘗試其他搜尋詞。', + 'mobile.sessions.showArchived': '顯示已封存 ({count})', + 'mobile.sessions.hideArchived': '隱藏已封存', + 'mobile.sessions.activeWorktreeAria': '作用中的工作樹', + 'mobile.sessions.activeProjectAria': '作用中的專案', + 'mobile.sessions.startNewChat': '開始新聊天', + 'mobile.sessions.newChat': '新聊天', + 'mobile.sessions.editOrder': '重新排序專案', + 'mobile.sessions.doneEditing': '完成', + 'mobile.sessions.editOrderHint': '拖曳把手或使用箭頭重新排序專案。點選勾號完成。', + 'mobile.sessions.dragHandleAria': '拖曳 {label} 以重新排序', + 'mobile.sessions.moveUpAria': '將 {label} 上移', + 'mobile.sessions.moveDownAria': '將 {label} 下移', + 'mobile.sessions.removeProjectAria': '移除 {label}', + 'mobile.sessions.cancelRemoveProjectAria': '取消移除 {label}', + 'mobile.sessions.confirmRemoveProject': '刪除', + 'mobile.sessions.confirmRemoveProjectAria': '確認移除 {label}', + 'mobile.sessions.toast.projectRemoved': '已移除 {label}', + 'mobile.sessions.showMore': '再顯示 {count} 個', + 'mobile.sessions.search.section.sessions': '會話', + 'mobile.sessions.search.section.archived': '已封存', + 'mobile.sessions.search.section.projects': '專案', + 'mobile.sessions.clearSearchAria': '清除搜尋', + 'mobile.header.noProject': '選擇專案', + 'mobile.header.activeSession': '作用中會話', + 'mobile.header.noSession': '沒有作用中會話', + 'mobile.sessions.openSheetAria': '開啟會話與專案', + 'mobile.sessions.closeSheetAria': '關閉會話與專案', + 'mobile.sessions.sheet.title': '會話', + 'mobile.sessions.sheet.description': '切換專案、開啟會話或開始新聊天。', + 'mobile.sessions.search.placeholder': '搜尋會話', + 'mobile.sessions.empty': '找不到會話。', + 'mobile.sessions.unassignedProject': '其他會話', + 'mobile.sessions.newSessionAria': '在此專案中開始新會話', + 'mobile.sessions.untitled': '未命名會話', + 'mobile.sessions.project.sessionsSingle': '1 個會話', + 'mobile.sessions.project.sessionsPlural': '{count} 個會話', + 'mobile.files.refreshAria': '重新整理檔案', + 'mobile.files.backToParentAria': '返回 {name}', + 'mobile.files.rootDirectory': '專案檔案', + 'mobile.files.search.placeholder': '搜尋檔案', + 'mobile.files.search.empty': '找不到檔案。', + 'mobile.files.parentDirectory': '上層目錄', + 'mobile.files.empty.noDirectory': '選擇專案以瀏覽檔案。', + 'mobile.files.empty.directory': '此目錄是空的。', + 'mobile.files.error.listFailed': '載入檔案失敗', + 'mobile.files.error.readUnavailable': '此執行環境無法使用檔案預覽。', + 'mobile.files.file.truncated': '行動版檔案預覽已截斷。', + 'mobile.files.copyPathAria': '複製檔案路徑', + 'mobile.files.copyContent': '複製內容', + 'mobile.files.copyContentAria': '複製檔案內容', + 'mobile.files.toast.pathCopied': '路徑已複製', + 'mobile.files.toast.contentCopied': '內容已複製', + 'mobile.files.toast.copyFailed': '複製失敗', + 'mobile.changes.placeholder.title': '變更', + 'mobile.changes.placeholder.description': '工作樹檢閱、同步與提交動作會顯示在這裡。', + 'mobile.changes.branchLabel': '分支:{branch}', + 'mobile.changes.noRemote': '沒有可用的 remote', + 'mobile.changes.cleanDescription': '此工作區沒有已變更的檔案。', + 'mobile.changes.diffDetail.subtitle': '唯讀 diff', + 'mobile.changes.diffDetail.loadFailed': '載入 diff 失敗', + 'mobile.changes.diffDetail.missingTitle': '檔案已不再有變更', + 'mobile.changes.diffDetail.missingDescription': '返回變更並重新整理清單。', + 'mobile.changes.diffDetail.imageUnavailable': '行動版變更尚不支援圖片 diff。', + 'mobile.settings.placeholder.title': '設定', + 'mobile.settings.placeholder.description': '行動版連線與應用程式設定會顯示在這裡。', + 'header.services.remoteUpdate.title': '遠端實例更新', + 'header.services.remoteUpdate.checking': '正在檢查更新...', + 'header.services.remoteUpdate.upToDate': '此實例已是最新。', + 'header.services.remoteUpdate.available': '此實例可更新到版本 {version}。', + 'header.services.remoteUpdate.error': '無法檢查遠端實例更新', + 'header.services.remoteUpdate.actions.open': '更新', + 'desktopHostSwitcher.status.checking': '檢查中', + 'desktopHostSwitcher.status.updateRecommended': '建議更新', + 'desktopHostSwitcher.status.incompatible': '不相容', + 'onboarding.remoteConnection.probe.updateRecommendedMessage': '此實例正在執行不同版本的 OpenChamber。你可以連線;如果功能異常,請更新兩端應用程式。', + 'onboarding.remoteConnection.probe.incompatibleMessage': '伺服器正在執行 OpenChamber,但與此應用程式版本不相容。請更新伺服器上的 OpenChamber 後重試。', + 'onboarding.desktopRecovery.remoteIncompatible.title': '需要更新伺服器', + 'onboarding.desktopRecovery.remoteIncompatible.description': '「{host}」上的 OpenChamber 伺服器與此應用程式版本不相容。請更新伺服器上的 OpenChamber 後重試。', 'layout.rightSidebar.git': 'Git', 'layout.rightSidebar.files': '檔案', 'layout.rightSidebar.context': '上下文', diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index 6bf4c8e9..7dd670d2 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -1,3 +1,5 @@ +import { runtimeFetch } from './runtime-fetch'; + export type MagicPromptId = | 'git.commit.generate.visible' | 'git.commit.generate.instructions' @@ -841,7 +843,7 @@ export const fetchMagicPromptOverrides = async (): Promise => { - const response = await fetch(`${API_ENDPOINT}/${encodeURIComponent(id)}`, { + const response = await runtimeFetch(`${API_ENDPOINT}/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -915,7 +917,7 @@ export const saveMagicPromptOverride = async (id: MagicPromptId, text: string): }; export const resetMagicPromptOverride = async (id: MagicPromptId): Promise => { - const response = await fetch(`${API_ENDPOINT}/${encodeURIComponent(id)}`, { + const response = await runtimeFetch(`${API_ENDPOINT}/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Accept: 'application/json' }, }); @@ -932,7 +934,7 @@ export const resetMagicPromptOverride = async (id: MagicPromptId): Promise => { - const response = await fetch(API_ENDPOINT, { + const response = await runtimeFetch(API_ENDPOINT, { method: 'DELETE', headers: { Accept: 'application/json' }, }); diff --git a/packages/ui/src/lib/mobileLayoutPreference.ts b/packages/ui/src/lib/mobileLayoutPreference.ts new file mode 100644 index 00000000..0508db4b --- /dev/null +++ b/packages/ui/src/lib/mobileLayoutPreference.ts @@ -0,0 +1,32 @@ +export type MobileLayoutPreference = 'default' | 'new'; + +const MOBILE_LAYOUT_PREFERENCE_KEY = 'openchamber-mobile-layout'; + +export const normalizeMobileLayoutPreference = (value: unknown): MobileLayoutPreference => { + return value === 'new' ? 'new' : 'default'; +}; + +export const getStoredMobileLayoutPreference = (): MobileLayoutPreference => { + if (typeof window === 'undefined') { + return 'default'; + } + + try { + return normalizeMobileLayoutPreference(window.localStorage.getItem(MOBILE_LAYOUT_PREFERENCE_KEY)); + } catch { + return 'default'; + } +}; + +export const setStoredMobileLayoutPreference = (value: MobileLayoutPreference): boolean => { + if (typeof window === 'undefined') { + return false; + } + + try { + window.localStorage.setItem(MOBILE_LAYOUT_PREFERENCE_KEY, value); + return true; + } catch { + return false; + } +}; diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts index 62e956da..692ba74d 100644 --- a/packages/ui/src/lib/openCodeStatus.ts +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -1,6 +1,9 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { getSyncSessions } from '@/sync/sync-refs'; import { useUIStore } from '@/stores/useUIStore'; +import { getRuntimeUrlResolver } from './runtime-url'; +import { opencodeClient } from './opencode/client'; +import { runtimeFetch } from './runtime-fetch'; declare const __APP_VERSION__: string | undefined; @@ -57,7 +60,7 @@ const safeFetch = async (input: string, timeoutMs = 6000): Promise const startedAt = Date.now(); try { - const resp = await fetch(input, { + const resp = await runtimeFetch(input, { method: 'GET', headers: { Accept: 'application/json' }, signal: controller.signal, @@ -152,14 +155,16 @@ export const buildOpenCodeStatusReport = async (): Promise => { const directory = getCurrentDirectory(); const eventStreamStatus = useUIStore.getState().eventStreamStatus; const origin = typeof window !== 'undefined' ? window.location.origin : ''; - const apiBase = origin ? `${origin.replace(/\/+$/, '')}/api/` : ''; + const urls = getRuntimeUrlResolver(); + const healthUrl = urls.health(); + const apiBase = urls.api('/api/'); const openChamberHealth: OpenChamberHealthSnapshot | null = await (async () => { - if (!origin) return null; + if (!healthUrl) return null; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { - const resp = await fetch(`${origin.replace(/\/+$/, '')}/health`, { + const resp = await runtimeFetch(healthUrl, { method: 'GET', headers: { Accept: 'application/json' }, signal: controller.signal, @@ -180,11 +185,11 @@ export const buildOpenCodeStatusReport = async (): Promise => { status: number | null; error: string | null; } = await (async () => { - if (!origin) return null; + if (!apiBase) return null; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 7000); try { - const resp = await fetch(`${origin.replace(/\/+$/, '')}/api/config/opencode-resolution`, { + const resp = await runtimeFetch(urls.api('/api/config/opencode-resolution'), { method: 'GET', headers: { Accept: 'application/json' }, signal: controller.signal, @@ -255,7 +260,8 @@ export const buildOpenCodeStatusReport = async (): Promise => { const lines: string[] = []; lines.push(`Time: ${now.toISOString()}`); lines.push(`OpenChamber version: ${appVersion}`); - lines.push(`Runtime: ${origin || '(unknown)'} (api=${origin ? origin + '/api' : '(unknown)'})`); + lines.push(`Runtime: ${origin || '(unknown)'} (api=${apiBase || '(unknown)'})`); + lines.push(`OpenCode SDK base: ${opencodeClient.getBaseUrl()}`); lines.push(`Event stream: ${eventStreamStatus}`); lines.push(`Directory: ${directory || '(none)'}`); lines.push(`Platform: ${platform}`); diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index cb8f6a89..264cc5b0 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -4,11 +4,13 @@ * Migrates from legacy /.openchamber/openchamber.json. */ -import type { FilesAPI, RuntimeAPIs } from './api/types'; +import type { FilesAPI } from './api/types'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getDesktopHomeDirectory } from './desktop'; import { isVSCodeRuntime } from './desktop'; -import { createProjectIdFromPath } from './projectId'; import { sanitizeStarterRefs, type DraftStarterRef } from './draftStarters'; +import { createProjectIdFromPath } from './projectId'; +import { runtimeFetch } from './runtime-fetch'; type ProjectRef = { id: string; path: string }; @@ -21,8 +23,7 @@ const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects']; * Get the runtime Files API if available (Desktop/VSCode). */ function getRuntimeFilesAPI(): FilesAPI | null { - if (typeof window === 'undefined') return null; - const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__; + const apis = getRegisteredRuntimeAPIs(); if (apis?.files) { return apis.files; } @@ -126,7 +127,7 @@ const getBaseUrl = (): string => { const postJson = async (url: string, body: unknown): Promise<{ ok: boolean; data: T | null }> => { try { - const response = await fetch(url, { + const response = await runtimeFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -171,7 +172,7 @@ const readTextFile = async (path: string): Promise => { } try { - const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`, + const response = await runtimeFetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`, { // Avoid conditional requests (304 + empty body). cache: 'no-store', @@ -208,7 +209,7 @@ const resolveHomeDirectory = async (): Promise => { // In some runtimes, window.__OPENCHAMBER_HOME__ can be workspace/project-root // scoped, which would incorrectly route writes into the project directory. try { - const response = await fetch(`${getBaseUrl()}/fs/home`, { + const response = await runtimeFetch(`${getBaseUrl()}/fs/home`, { // Avoid conditional requests (304 + empty body). cache: 'no-store', }); diff --git a/packages/ui/src/lib/openchamberEvents.ts b/packages/ui/src/lib/openchamberEvents.ts index 5468345b..bf481885 100644 --- a/packages/ui/src/lib/openchamberEvents.ts +++ b/packages/ui/src/lib/openchamberEvents.ts @@ -1,3 +1,6 @@ +import { getRuntimeUrlResolver } from './runtime-url'; +import { subscribeRuntimeEndpointChanged } from './runtime-switch'; + export type ScheduledTaskRanEvent = { type: 'scheduled-task-ran'; projectId: string; @@ -14,6 +17,7 @@ let eventSource: EventSource | null = null; let reconnectTimer: ReturnType | null = null; let heartbeatTimer: ReturnType | null = null; let reconnectAttempt = 0; +let runtimeChangeUnsubscribe: (() => void) | null = null; const listeners = new Set(); const MAX_RECONNECT_DELAY_MS = 30_000; @@ -129,7 +133,7 @@ const connect = () => { cleanupSource(); - const source = new EventSource('/api/openchamber/events'); + const source = new EventSource(getRuntimeUrlResolver().sse('/api/openchamber/events')); source.onopen = () => { resetHeartbeatTimer(); }; @@ -150,8 +154,23 @@ const connect = () => { eventSource = source; }; +const ensureRuntimeChangeSubscription = () => { + if (runtimeChangeUnsubscribe || typeof window === 'undefined') return; + runtimeChangeUnsubscribe = subscribeRuntimeEndpointChanged(() => { + cleanupSource(); + reconnectAttempt = 0; + connect(); + }); +}; + +const cleanupRuntimeChangeSubscription = () => { + runtimeChangeUnsubscribe?.(); + runtimeChangeUnsubscribe = null; +}; + export const subscribeOpenchamberEvents = (listener: Listener): (() => void) => { listeners.add(listener); + ensureRuntimeChangeSubscription(); connect(); return () => { @@ -163,6 +182,7 @@ export const subscribeOpenchamberEvents = (listener: Listener): (() => void) => } reconnectAttempt = 0; cleanupSource(); + cleanupRuntimeChangeSubscription(); } }; }; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index b35e49ad..5f159097 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -1,5 +1,5 @@ import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2"; -import type { FilesAPI, RuntimeAPIs } from "../api/types"; +import type { FilesAPI } from "../api/types"; import { getDesktopHomeDirectory } from "../desktop"; import type { Session, @@ -15,6 +15,9 @@ import type { import type { PermissionRequest } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"; +import { getRuntimeUrlResolver } from "@/lib/runtime-url"; +import { runtimeFetch } from "@/lib/runtime-fetch"; +import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; import { assertProviderCircuitClosed, recordProviderSuccess, @@ -45,6 +48,31 @@ function formatSdkError(error: unknown): string { return String(error); } } +type SdkResult = { + data?: T; + error?: unknown; + response?: { status?: number }; +}; + +function unwrapSdkData(result: SdkResult, operation: string): T { + if (result.error) { + const status = result.response?.status; + throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`); + } + if (result.data === undefined || result.data === null) { + throw new Error(`${operation} failed: empty response`); + } + return result.data; +} + +function unwrapSdkOptional(result: SdkResult, operation: string): T | undefined { + if (result.error) { + const status = result.response?.status; + throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`); + } + return result.data; +} + const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//; const ID_RANDOM_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; const ID_RANDOM_LENGTH = 14; @@ -116,29 +144,19 @@ const ensureAbsoluteBaseUrl = (candidate: string): string => { } }; -const resolveDesktopBaseUrl = (): string | null => { - if (typeof window === "undefined") { +const resolveRuntimeBaseUrl = (): string | null => { + try { + return getRuntimeUrlResolver().api('/api'); + } catch { return null; } - const desktopServer = (window as typeof window & { - __OPENCHAMBER_DESKTOP_SERVER__?: { origin: string; apiPrefix?: string }; - __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs; - }).__OPENCHAMBER_DESKTOP_SERVER__; +}; - const isDesktop = Boolean( - (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__?.runtime?.isDesktop - ); - - if (!desktopServer || !isDesktop) { - return null; - } - - const origin = typeof desktopServer.origin === "string" && desktopServer.origin.length > 0 ? desktopServer.origin : null; - if (!origin) { - return null; - } - - return `${origin}/api`; +const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => { + return createOpencodeClient({ + ...config, + fetch: runtimeFetch, + }); }; interface App { @@ -192,10 +210,7 @@ const normalizeFsPath = (path: string): string => path.replace(/\\/g, "/"); const FS_LIST_CACHE_TTL_MS = 400; const getDesktopFilesApi = (): FilesAPI | null => { - if (typeof window === "undefined") { - return null; - } - const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__; + const apis = getRegisteredRuntimeAPIs(); if (apis && apis.runtime?.isDesktop && apis.files) { return apis.files; } @@ -212,16 +227,29 @@ class OpencodeService { private listDirectoryCache: Map = new Map(); constructor(baseUrl: string = DEFAULT_BASE_URL) { - const desktopBase = resolveDesktopBaseUrl(); - const requestedBaseUrl = desktopBase || baseUrl; + const runtimeBase = resolveRuntimeBaseUrl(); + const requestedBaseUrl = runtimeBase || baseUrl; this.baseUrl = ensureAbsoluteBaseUrl(requestedBaseUrl); - this.client = createOpencodeClient({ baseUrl: this.baseUrl }); + this.client = createRuntimeOpencodeClient({ baseUrl: this.baseUrl }); } getBaseUrl(): string { return this.baseUrl; } + reconnectToRuntimeBaseUrl(): void { + const runtimeBase = resolveRuntimeBaseUrl(); + const nextBaseUrl = ensureAbsoluteBaseUrl(runtimeBase || DEFAULT_BASE_URL); + if (nextBaseUrl === this.baseUrl) { + return; + } + this.baseUrl = nextBaseUrl; + this.client = createRuntimeOpencodeClient({ baseUrl: this.baseUrl }); + this.scopedClients.clear(); + this.listDirectoryInFlight.clear(); + this.listDirectoryCache.clear(); + } + /** Expose the raw SDK client for direct use (e.g., SyncProvider) */ getSdkClient(): OpencodeClient { return this.client; @@ -243,7 +271,7 @@ class OpencodeService { if (existing) { return existing; } - const scoped = createOpencodeClient({ baseUrl: this.baseUrl, directory: normalized }); + const scoped = createRuntimeOpencodeClient({ baseUrl: this.baseUrl, directory: normalized }); this.scopedClients.set(key, scoped); return scoped; } @@ -328,11 +356,14 @@ class OpencodeService { } const previousDirectory = this.currentDirectory; - this.currentDirectory = this.normalizeCandidatePath(directory) ?? directory; + const scopedDirectory = this.normalizeCandidatePath(directory) ?? directory; + this.currentDirectory = scopedDirectory; try { return await fn(); } finally { - this.currentDirectory = previousDirectory; + if (this.currentDirectory === scopedDirectory) { + this.currentDirectory = previousDirectory; + } } }; @@ -444,14 +475,14 @@ class OpencodeService { return Array.isArray(response.data) ? response.data : []; } - async createSession(params?: { parentID?: string; title?: string }): Promise { + async createSession(params?: { parentID?: string; title?: string }, directory?: string | null): Promise { + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; const response = await this.client.session.create({ - ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), + ...(requestDirectory ? { directory: requestDirectory } : {}), parentID: params?.parentID, - title: params?.title + title: params?.title, }); - if (!response.data) throw new Error('Failed to create session'); - return response.data; + return unwrapSdkData(response, 'session.create'); } async getSession(id: string): Promise { @@ -459,26 +490,34 @@ class OpencodeService { sessionID: id, ...(this.currentDirectory ? { directory: this.currentDirectory } : {}) }); - if (!response.data) throw new Error('Session not found'); - return response.data; + return unwrapSdkData(response, 'session.get'); } - async deleteSession(id: string): Promise { + async deleteSession(id: string, directory?: string | null): Promise { + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; const response = await this.client.session.delete({ sessionID: id, - ...(this.currentDirectory ? { directory: this.currentDirectory } : {}) + ...(requestDirectory ? { directory: requestDirectory } : {}), }); - return response.data || false; + return unwrapSdkOptional(response, 'session.delete') === true; } - async updateSession(id: string, title?: string): Promise { + async updateSession( + id: string, + patch: { title?: string; time?: { archived?: number | null } }, + directory?: string | null, + ): Promise { + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; + const sdkPatch = { + ...(patch.title !== undefined ? { title: patch.title } : {}), + ...(patch.time?.archived !== undefined && patch.time.archived !== null ? { time: { archived: patch.time.archived } } : {}), + }; const response = await this.client.session.update({ sessionID: id, - ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), - title + ...(requestDirectory ? { directory: requestDirectory } : {}), + ...sdkPatch, }); - if (!response.data) throw new Error('Failed to update session'); - return response.data; + return unwrapSdkData(response, 'session.update'); } async getSessionMessages(id: string, limit?: number): Promise<{ info: Message; parts: Part[] }[]> { @@ -487,30 +526,20 @@ class OpencodeService { ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), ...(typeof limit === 'number' ? { limit } : {}), }); - return response.data || []; + return unwrapSdkData(response, 'session.messages'); } async getSessionTodos(sessionId: string): Promise> { try { - const base = this.baseUrl.replace(/\/$/, ""); - const url = new URL(`${base}/session/${encodeURIComponent(sessionId)}/todo`); - - if (this.currentDirectory && this.currentDirectory.length > 0) { - url.searchParams.set("directory", this.currentDirectory); - } - - const response = await fetch(url.toString(), { - method: "GET", - headers: { - Accept: "application/json", - }, + const response = await this.client.session.todo({ + sessionID: sessionId, + ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), }); - - if (!response.ok) { + if (response.error) { return []; } - const data = await response.json().catch(() => null); + const data = response.data; if (!data || !Array.isArray(data)) { return []; } @@ -688,6 +717,7 @@ class OpencodeService { schema: Record; retryCount?: number; }; + directory?: string | null; }): Promise { // Reuse one client-side message ID across retries. The server accepts this // as the real user message ID, making ambiguous network retries idempotent. @@ -756,30 +786,10 @@ class OpencodeService { throw new Error('Message must have at least one part (text or file)'); } - if (this.currentDirectory) { - await waitForWorktreeBootstrap(this.currentDirectory); - } + const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory; - // Use async prompt endpoint so the client doesn't block waiting - // for model work (SSE will deliver output/status). - // This avoids 504s from proxy timeouts on long-running turns. - const base = this.baseUrl.replace(/\/+$/, ''); - let url: URL; - try { - url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`); - if (this.currentDirectory) { - url.searchParams.set('directory', this.currentDirectory); - } - } catch (error) { - console.error('[git-generation][browser] failed to build prompt_async URL', { - baseUrl: this.baseUrl, - normalizedBase: base, - sessionId: params.id, - directory: this.currentDirectory, - message: error instanceof Error ? error.message : String(error), - error, - }); - throw error; + if (requestDirectory) { + await waitForWorktreeBootstrap(requestDirectory); } if (params.format) { @@ -789,7 +799,7 @@ class OpencodeService { modelID: params.modelID, agent: params.agent, variant: params.variant, - directory: this.currentDirectory, + directory: requestDirectory, baseUrl: this.baseUrl, formatType: params.format.type, }); @@ -801,24 +811,27 @@ class OpencodeService { for (let attempt = 0; attempt < 3; attempt++) { try { - response = await fetch(url.toString(), { - method: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json', + const result = await this.client.session.promptAsync({ + sessionID: params.id, + ...(requestDirectory ? { directory: requestDirectory } : {}), + model: { + providerID: params.providerID, + modelID: params.modelID, }, - body: JSON.stringify({ - model: { - providerID: params.providerID, - modelID: params.modelID, - }, - agent: params.agent, - variant: params.variant, - messageID: messageId, - ...(params.format ? { format: params.format } : {}), - parts, - }), + agent: params.agent, + variant: params.variant, + messageID: messageId, + ...(params.format ? { format: params.format } : {}), + parts, }); + if (result.response instanceof Response) { + response = result.response; + } else if (result.error) { + const status = (result as SdkResult).response?.status || 500; + response = new Response(JSON.stringify(result.error), { status }); + } else { + response = new Response(JSON.stringify(result.data ?? true), { status: 200 }); + } } catch (error) { if (attempt < 2 && isRetryableFetchError(error)) { const delay = getRetryDelayMs(attempt); @@ -873,6 +886,7 @@ class OpencodeService { variant?: string; files?: Array; messageId?: string; + directory?: string | null; }): Promise { const tempMessageId = params.messageId ?? ascendingId("msg"); @@ -883,42 +897,21 @@ class OpencodeService { } } - const base = this.baseUrl.replace(/\/+$/, ''); - const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/command`); - if (this.currentDirectory) { - url.searchParams.set('directory', this.currentDirectory); - } + const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory; - const payload: Record = { + const response = await this.client.session.command({ + sessionID: params.id, + ...(requestDirectory ? { directory: requestDirectory } : {}), command: params.command, arguments: params.arguments ?? '', model: `${params.providerID}/${params.modelID}`, - ...(params.agent ? { agent: params.agent } : {}), - ...(params.variant ? { variant: params.variant } : {}), + agent: params.agent, + variant: params.variant, ...(parts.length > 0 ? { parts } : {}), messageID: tempMessageId, - }; - - const response = await fetch(url.toString(), { - method: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json', - }, - body: JSON.stringify(payload), }); - if (!response.ok) { - let detail = ''; - try { - detail = await response.text(); - } catch { - // ignore - } - const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : ''; - throw new Error(`Failed to run command (${response.status})${suffix}`); - } - + unwrapSdkOptional(response, 'session.command'); return tempMessageId; } @@ -933,15 +926,46 @@ class OpencodeService { return Boolean(response.data); } - async revertSession(sessionId: string, messageId: string, partId?: string): Promise { + async shellSession(params: { + sessionId: string; + command: string; + agent: string; + model: { providerID: string; modelID: string }; + messageId?: string; + directory?: string | null; + }): Promise<{ info: Message; parts: Part[] }> { + const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory; + const response = await this.client.session.shell({ + sessionID: params.sessionId, + ...(requestDirectory ? { directory: requestDirectory } : {}), + messageID: params.messageId, + agent: params.agent, + model: params.model, + command: params.command, + }); + return unwrapSdkData(response, 'session.shell') as { info: Message; parts: Part[] }; + } + + async revertSession(sessionId: string, messageId: string, partId?: string, directory?: string | null): Promise { + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; const response = await this.client.session.revert({ sessionID: sessionId, - ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), + ...(requestDirectory ? { directory: requestDirectory } : {}), messageID: messageId, - partID: partId + partID: partId, }); - if (!response.data) throw new Error('Failed to revert session'); - return response.data; + return unwrapSdkData(response, 'session.revert'); + } + + async summarizeSession(sessionId: string, providerId: string, modelId: string, directory?: string | null): Promise { + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; + const response = await this.client.session.summarize({ + sessionID: sessionId, + ...(requestDirectory ? { directory: requestDirectory } : {}), + providerID: providerId, + modelID: modelId, + }); + return unwrapSdkOptional(response, 'session.summarize') === true; } async unrevertSession(sessionId: string): Promise { @@ -949,22 +973,17 @@ class OpencodeService { sessionID: sessionId, ...(this.currentDirectory ? { directory: this.currentDirectory } : {}) }); - if (!response.data) throw new Error('Failed to unrevert session'); - return response.data; + return unwrapSdkData(response, 'session.unrevert'); } - async forkSession(sessionId: string, messageId?: string): Promise { + async forkSession(sessionId: string, messageId?: string, directory?: string | null): Promise { + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; const response = await this.client.session.fork({ sessionID: sessionId, - ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), - messageID: messageId + ...(requestDirectory ? { directory: requestDirectory } : {}), + messageID: messageId, }); - - if (!response.data) { - throw new Error('Failed to fork session'); - } - - return response.data; + return unwrapSdkData(response, 'session.fork'); } async getSessionStatus(): Promise< @@ -985,31 +1004,12 @@ class OpencodeService { directory: string | null | undefined ): Promise | null> { try { - const base = this.baseUrl.replace(/\/$/, ""); - const url = new URL(`${base}/session/status`); - const trimmedDirectory = typeof directory === "string" ? directory.trim() : ""; - if (trimmedDirectory.length > 0) { - url.searchParams.set("directory", trimmedDirectory); - } - - const response = await fetch(url.toString(), { - method: "GET", - headers: { - Accept: "application/json", - }, - }); - - if (!response.ok) { + const result = await this.client.session.status(trimmedDirectory ? { directory: trimmedDirectory } : undefined); + if (result.error || !result.data || typeof result.data !== "object") { return null; } - - const data = await response.json().catch(() => null); - if (!data || typeof data !== "object") { - return null; - } - - return data as Record< + return result.data as Record< string, { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number } >; @@ -1033,8 +1033,7 @@ class OpencodeService { Record | null > { try { - // Web server endpoint - use relative path that works with both dev and prod - const response = await fetch('/api/session-activity', { + const response = await runtimeFetch('/api/session-activity', { method: 'GET', headers: { Accept: 'application/json', @@ -1075,15 +1074,16 @@ class OpencodeService { async replyToPermission( requestId: string, reply: 'once' | 'always' | 'reject', - options?: { message?: string } + options?: { message?: string; directory?: string | null } ): Promise { - const result = await this.client.permission.reply({ + const requestDirectory = this.normalizeCandidatePath(options?.directory ?? null) ?? this.currentDirectory; + const response = await this.client.permission.reply({ requestID: requestId, - ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), + ...(requestDirectory ? { directory: requestDirectory } : {}), reply, ...(options?.message ? { message: options.message } : {}), }); - return result.data || false; + return unwrapSdkOptional(response, 'permission.reply') === true; } /** @@ -1138,7 +1138,7 @@ class OpencodeService { } // Questions ("ask" tool) - async replyToQuestion(requestId: string, answers: string[] | string[][]): Promise { + async replyToQuestion(requestId: string, answers: string[] | string[][], directory?: string | null): Promise { const normalizedAnswers: string[][] = (() => { if (!Array.isArray(answers) || answers.length === 0) { return []; @@ -1149,12 +1149,13 @@ class OpencodeService { return [answers as string[]]; })(); - const result = await this.client.question.reply({ + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; + const response = await this.client.question.reply({ requestID: requestId, - ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), + ...(requestDirectory ? { directory: requestDirectory } : {}), answers: normalizedAnswers, }); - return result.data || false; + return unwrapSdkOptional(response, 'question.reply') === true; } async rejectQuestion(requestId: string): Promise { @@ -1162,7 +1163,7 @@ class OpencodeService { requestID: requestId, ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), }); - return result.data || false; + return unwrapSdkOptional(result, 'question.reject') === true; } /** @@ -1225,24 +1226,8 @@ class OpencodeService { async updateConfig(config: Record): Promise { // IMPORTANT: Do NOT pass directory parameter for config updates // The config should be global, not directory-specific - const url = `${this.baseUrl}/config`; - - const response = await fetch(url, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(config) - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('[OpencodeClient] Failed to update config:', response.status, errorText); - throw new Error(`Failed to update config: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - return data; + const response = await this.client.config.update({ config: config as Config }); + return unwrapSdkData(response, 'global.config.update'); } /** @@ -1314,25 +1299,11 @@ class OpencodeService { // File Operations async readFile(path: string): Promise { try { - // For now, we'll use a placeholder implementation - // In a real implementation, this would call an API endpoint to read the file - const response = await fetch(`${this.baseUrl}/files/read`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - path, - directory: this.currentDirectory - }) + const response = await this.client.file.read({ + path, + ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), }); - - if (!response.ok) { - throw new Error(`Failed to read file: ${response.statusText}`); - } - - const data = await response.text(); - return data; + return String(unwrapSdkData(response, 'file.read')); } catch { // Return placeholder for development return `// Content of ${path}\n// This would be loaded from the server`; @@ -1342,20 +1313,12 @@ class OpencodeService { async listFiles(directory?: string): Promise[]> { try { const targetDir = directory || this.currentDirectory || '/'; - const response = await fetch(`${this.baseUrl}/files/list`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ directory: targetDir }) + const response = await this.client.file.list({ + path: targetDir, + ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), }); - - if (!response.ok) { - throw new Error(`Failed to list files: ${response.statusText}`); - } - - const data = await response.json(); - return data; + const data = unwrapSdkData(response, 'file.list'); + return Array.isArray(data) ? data as Record[] : []; } catch { // Return mock data for development return []; @@ -1364,41 +1327,35 @@ class OpencodeService { // Command Management async listCommands(): Promise> { - try { - const response = await this.client.command.list( - this.currentDirectory ? { directory: this.currentDirectory } : undefined - ); - // Return only lightweight info for autocomplete - return (response.data || []).map((cmd: Record) => ({ - name: cmd.name as string, - description: cmd.description as string | undefined, - agent: cmd.agent as string | undefined, - model: cmd.model as string | undefined, - source: cmd.source as string | undefined, - // Intentionally excluding template to keep memory usage low - })); - } catch { - return []; - } + const response = await this.client.command.list( + this.currentDirectory ? { directory: this.currentDirectory } : undefined + ); + const commands = unwrapSdkData(response, 'command.list'); + // Return only lightweight info for autocomplete + return (commands || []).map((cmd: Record) => ({ + name: cmd.name as string, + description: cmd.description as string | undefined, + agent: cmd.agent as string | undefined, + model: cmd.model as string | undefined, + source: cmd.source as string | undefined, + // Intentionally excluding template to keep memory usage low + })); } async listCommandsWithDetails(): Promise> { - try { - const response = await this.client.command.list( - this.currentDirectory ? { directory: this.currentDirectory } : undefined - ); - // Return full command details including template - return (response.data || []).map((cmd: Record) => ({ - name: cmd.name as string, - description: cmd.description as string | undefined, - agent: cmd.agent as string | undefined, - model: cmd.model as string | undefined, - source: cmd.source as string | undefined, - template: cmd.template as string | undefined, - })); - } catch { - return []; - } + const response = await this.client.command.list( + this.currentDirectory ? { directory: this.currentDirectory } : undefined + ); + const commands = unwrapSdkData(response, 'command.list'); + // Return full command details including template + return (commands || []).map((cmd: Record) => ({ + name: cmd.name as string, + description: cmd.description as string | undefined, + agent: cmd.agent as string | undefined, + model: cmd.model as string | undefined, + source: cmd.source as string | undefined, + template: cmd.template as string | undefined, + })); } async listSkillsWithDetails(): Promise> { @@ -1467,7 +1424,7 @@ class OpencodeService { } else { healthUrl = `${normalizedBase}/health`; } - const response = await fetch(healthUrl); + const response = await runtimeFetch(healthUrl); if (!response.ok) { return false; } @@ -1505,7 +1462,7 @@ class OpencodeService { ...(options?.allowOutsideWorkspace ? { allowOutsideWorkspace: true } : {}), }; - const response = await fetch(`${this.baseUrl}/fs/mkdir`, { + const response = await runtimeFetch(`${this.baseUrl}/fs/mkdir`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1523,7 +1480,7 @@ class OpencodeService { } async cloneRepository(input: { remoteUrl: string; destinationPath: string; gitIdentityId?: string | null }): Promise<{ success: boolean; path: string; output?: string }> { - const response = await fetch(`${this.baseUrl}/fs/clone`, { + const response = await runtimeFetch(`${this.baseUrl}/fs/clone`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1589,7 +1546,7 @@ class OpencodeService { params.set('respectGitignore', 'true'); } const query = params.toString(); - const response = await fetch(`${this.baseUrl}/fs/list${query ? `?${query}` : ''}`); + const response = await runtimeFetch(`${this.baseUrl}/fs/list${query ? `?${query}` : ''}`); if (!response.ok) { const error = await response.json().catch(() => ({})); const message = typeof error.error === 'string' ? error.error : 'Failed to list directory'; @@ -1677,7 +1634,7 @@ class OpencodeService { } try { - const response = await fetch(`${this.baseUrl}/fs/home`, { + const response = await runtimeFetch(`${this.baseUrl}/fs/home`, { method: 'GET', headers: { Accept: 'application/json' @@ -1714,7 +1671,7 @@ class OpencodeService { console.log('[OpencodeClient] POST', url, 'with path:', directoryPath); try { - const response = await fetch(url, { + const response = await runtimeFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/packages/ui/src/lib/passkeys.ts b/packages/ui/src/lib/passkeys.ts index 8faa26b2..820eb449 100644 --- a/packages/ui/src/lib/passkeys.ts +++ b/packages/ui/src/lib/passkeys.ts @@ -4,6 +4,7 @@ import { WebAuthnAbortService, WebAuthnError, } from '@simplewebauthn/browser'; +import { runtimeFetch } from './runtime-fetch'; const PASSKEY_AUTH_OPTIONS_ENDPOINT = '/auth/passkey/authenticate/options'; const PASSKEY_AUTH_VERIFY_ENDPOINT = '/auth/passkey/authenticate/verify'; @@ -29,6 +30,13 @@ export type StoredPasskey = { backedUp: boolean; }; +type PasskeyAuthenticationOptions = { + issueClientToken?: boolean; + clientLabel?: string; + clientKind?: string; + dedupeKey?: string; +}; + export const defaultPasskeyStatus: PasskeyStatus = { enabled: false, hasPasskeys: false, @@ -36,7 +44,7 @@ export const defaultPasskeyStatus: PasskeyStatus = { rpID: null, }; -const postJson = async (url: string, body?: unknown): Promise => fetch(url, { +const postJson = async (url: string, body?: unknown): Promise => runtimeFetch(url, { method: 'POST', credentials: 'include', headers: { @@ -108,7 +116,7 @@ export const registerCurrentDevicePasskey = async () => { return verifyResponse.json().catch(() => null); }; -export const authenticateWithPasskey = async (trustDevice: boolean) => { +export const authenticateWithPasskey = async (trustDevice: boolean, options: PasskeyAuthenticationOptions = {}) => { const support = getPasskeySupportState(); if (!support.supported) { throw new Error(support.reason); @@ -125,6 +133,10 @@ export const authenticateWithPasskey = async (trustDevice: boolean) => { requestId, response: authResponse, trustDevice, + issueClientToken: options.issueClientToken === true, + clientLabel: options.clientLabel, + clientKind: options.clientKind, + dedupeKey: options.dedupeKey, }); if (!verifyResponse.ok) { @@ -135,7 +147,7 @@ export const authenticateWithPasskey = async (trustDevice: boolean) => { }; export const fetchPasskeyStatus = async (): Promise => { - const response = await fetch(PASSKEY_STATUS_ENDPOINT, { + const response = await runtimeFetch(PASSKEY_STATUS_ENDPOINT, { method: 'GET', credentials: 'include', headers: { @@ -157,7 +169,7 @@ export const fetchPasskeyStatus = async (): Promise => { }; export const fetchStoredPasskeys = async (): Promise => { - const response = await fetch(PASSKEY_LIST_ENDPOINT, { + const response = await runtimeFetch(PASSKEY_LIST_ENDPOINT, { method: 'GET', credentials: 'include', headers: { @@ -174,7 +186,7 @@ export const fetchStoredPasskeys = async (): Promise => { }; export const revokeStoredPasskey = async (id: string) => { - const response = await fetch(`${PASSKEY_LIST_ENDPOINT}/${encodeURIComponent(id)}`, { + const response = await runtimeFetch(`${PASSKEY_LIST_ENDPOINT}/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include', headers: { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 96faf9aa..7c69cf45 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -7,8 +7,9 @@ import { setDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { sanitizeStarterRefs } from '@/lib/draftStarters'; +import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; +import { runtimeFetch } from '@/lib/runtime-fetch'; const persistToLocalStorage = (settings: DesktopSettings) => { if (typeof window === 'undefined') { @@ -1110,7 +1111,7 @@ const fetchWebSettings = async (): Promise => { } try { - const response = await fetch('/api/config/settings', { + const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' }, }); @@ -1225,7 +1226,7 @@ const _flushSettingsUpdate = async (): Promise => { } try { - const response = await fetch('/api/config/settings', { + const response = await runtimeFetch('/api/config/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json', diff --git a/packages/ui/src/lib/preview/screenshot-capture.ts b/packages/ui/src/lib/preview/screenshot-capture.ts index 15fa9814..eb99f0c3 100644 --- a/packages/ui/src/lib/preview/screenshot-capture.ts +++ b/packages/ui/src/lib/preview/screenshot-capture.ts @@ -1,5 +1,6 @@ import { snapdom } from '@zumer/snapdom'; import { getFontEmbedCSS, toJpeg } from 'html-to-image'; +import { runtimeFetch } from '@/lib/runtime-fetch'; export type PreviewElementMetadata = { frame: 'top'; @@ -200,7 +201,7 @@ const TRANSPARENT_IMAGE_PLACEHOLDER = 'data:image/png;base64,iVBORw0KGgoAAAANSUh // the proxy id, so a stale persisted entry would 404 after a server restart. // Entries are evicted on registration error (refetched) or when the upstream // returns 403 (cookie expired) / 404 (target unknown) at iframe load time. -export type CachedProxyTarget = { proxyBasePath: string; expiresAt: number }; +export type CachedProxyTarget = { proxyBasePath: string; previewToken?: string; expiresAt: number }; export const previewProxyTargetCache = new Map(); const previewProxyTargetRequests = new Map>(); const PREVIEW_PROXY_CACHE_SAFETY_MS = 30_000; @@ -475,7 +476,7 @@ const getExternalResourceProxyUrl = async (url: URL): Promise => { const existingRequest = previewProxyTargetRequests.get(targetKey); const request = existingRequest ?? (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', diff --git a/packages/ui/src/lib/projectMeta.ts b/packages/ui/src/lib/projectMeta.ts index 770e568b..a5f14b55 100644 --- a/packages/ui/src/lib/projectMeta.ts +++ b/packages/ui/src/lib/projectMeta.ts @@ -1,7 +1,26 @@ +import React from 'react'; import type { ProjectEntry } from '@/lib/api/types'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import type { IconName } from "@/components/icon/icons"; type ThemeVariant = 'light' | 'dark'; +export type ProjectIconImageOptions = { themeVariant?: ThemeVariant; iconColor?: string }; + +const PROJECT_ICON_OBJECT_URL_CACHE_LIMIT = 200; + +type ProjectIconObjectUrlCacheEntry = { + url?: string; + promise?: Promise; +}; + +type ProjectIconObjectUrlRequest = { + cacheKey: string; + projectId: string; + query: URLSearchParams; +}; + +const projectIconObjectUrlCache = new Map(); export const PROJECT_ICONS: Array<{ key: string; Icon: IconName; label: string }> = [ { key: 'code', Icon: 'code-box', label: 'Code' }, @@ -45,21 +64,172 @@ export const PROJECT_COLOR_MAP: Record = Object.fromEntries( PROJECT_COLORS.map((c) => [c.key, c.cssVar]) ); -export const getProjectIconImageUrl = ( - project: Pick, - options?: { themeVariant?: ThemeVariant; iconColor?: string }, -): string | null => { - if (!project.iconImage || typeof project.iconImage.updatedAt !== 'number' || project.iconImage.updatedAt <= 0) { +const buildProjectIconQuery = (updatedAt: number | null | undefined, options?: ProjectIconImageOptions): URLSearchParams | null => { + if (typeof updatedAt !== 'number' || updatedAt <= 0) { return null; } - const params = new URLSearchParams({ v: String(project.iconImage.updatedAt) }); + const params = new URLSearchParams({ v: String(updatedAt) }); if (typeof options?.iconColor === 'string' && options.iconColor.trim()) { params.set('iconColor', options.iconColor.trim()); } if (options?.themeVariant === 'light' || options?.themeVariant === 'dark') { params.set('theme', options.themeVariant); } - - return `/api/projects/${encodeURIComponent(project.id)}/icon?${params.toString()}`; + return params; +}; + +const buildProjectIconObjectUrlCacheKey = ( + projectId: string, + query: URLSearchParams, +): string | null => { + if (!projectId) return null; + return [ + getRuntimeApiBaseUrl() || 'same-origin', + projectId, + query.toString(), + ].join('|'); +}; + +const buildProjectIconObjectUrlRequest = ( + projectId: string, + updatedAt: number | null | undefined, + options?: ProjectIconImageOptions, +): ProjectIconObjectUrlRequest | null => { + const query = buildProjectIconQuery(updatedAt, options); + if (!query) return null; + + const cacheKey = buildProjectIconObjectUrlCacheKey(projectId, query); + if (!cacheKey) return null; + + return { cacheKey, projectId, query }; +}; + +const trimProjectIconObjectUrlCache = (): void => { + while (projectIconObjectUrlCache.size > PROJECT_ICON_OBJECT_URL_CACHE_LIMIT) { + const firstKey = projectIconObjectUrlCache.keys().next().value; + if (typeof firstKey !== 'string') return; + const entry = projectIconObjectUrlCache.get(firstKey); + if (entry?.url && typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(entry.url); + } + projectIconObjectUrlCache.delete(firstKey); + } +}; + +const loadProjectIconObjectUrl = ( + request: ProjectIconObjectUrlRequest, +): Promise => { + const cached = projectIconObjectUrlCache.get(request.cacheKey); + if (cached?.url) return Promise.resolve(cached.url); + if (cached?.promise) return cached.promise; + + const promise = runtimeFetch(`/api/projects/${encodeURIComponent(request.projectId)}/icon`, { + method: 'GET', + headers: { Accept: 'image/*' }, + query: request.query, + }) + .then(async (response) => { + if (!response.ok) return null; + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return null; + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + projectIconObjectUrlCache.set(request.cacheKey, { url }); + trimProjectIconObjectUrlCache(); + return url; + }) + .catch(() => null) + .finally(() => { + const entry = projectIconObjectUrlCache.get(request.cacheKey); + if (entry?.promise === promise && !entry.url) { + projectIconObjectUrlCache.delete(request.cacheKey); + } + }); + + projectIconObjectUrlCache.set(request.cacheKey, { promise }); + return promise; +}; + +export const useProjectIconImageObjectUrl = ( + project: Pick, + options?: ProjectIconImageOptions, +): string | null => { + const projectId = project.id; + const updatedAt = project.iconImage?.updatedAt; + const themeVariant = options?.themeVariant; + const iconColor = options?.iconColor; + const request = React.useMemo(() => buildProjectIconObjectUrlRequest(projectId, updatedAt, { themeVariant, iconColor }), [ + projectId, + updatedAt, + themeVariant, + iconColor, + ]); + const [url, setUrl] = React.useState(() => { + return request ? projectIconObjectUrlCache.get(request.cacheKey)?.url ?? null : null; + }); + + React.useEffect(() => { + if (!request) { + setUrl(null); + return; + } + + const cached = projectIconObjectUrlCache.get(request.cacheKey)?.url; + if (cached) { + setUrl(cached); + return; + } + + let cancelled = false; + setUrl(null); + void loadProjectIconObjectUrl(request).then((nextUrl) => { + if (!cancelled) setUrl(nextUrl); + }); + + return () => { + cancelled = true; + }; + }, [request]); + + return url; +}; + +export type ProjectIconImageProps = { + project: Pick; + options?: ProjectIconImageOptions; + className?: string; + alt?: string; + draggable?: boolean; + fallback?: React.ReactNode; + onError?: React.ReactEventHandler; +}; + +export const ProjectIconImage: React.FC = ({ + project, + options, + className, + alt = '', + draggable = false, + fallback = null, + onError, +}) => { + const src = useProjectIconImageObjectUrl(project, options); + const [failed, setFailed] = React.useState(false); + + React.useEffect(() => { + setFailed(false); + }, [src, project.id, project.iconImage?.updatedAt]); + + if (!src || failed) return React.createElement(React.Fragment, null, fallback); + + return React.createElement('img', { + src, + alt, + className, + draggable, + onError: (event: React.SyntheticEvent) => { + setFailed(true); + onError?.(event); + }, + }); }; diff --git a/packages/ui/src/lib/responseStyle.ts b/packages/ui/src/lib/responseStyle.ts index 8ed6e33d..d01825d8 100644 --- a/packages/ui/src/lib/responseStyle.ts +++ b/packages/ui/src/lib/responseStyle.ts @@ -1,3 +1,5 @@ +import { runtimeFetch } from './runtime-fetch'; + export const RESPONSE_STYLE_PRESETS = ['concise', 'detailed', 'mentor', 'pushback', 'noFiller', 'matchEnergy', 'warmPeer'] as const; export type ResponseStylePreset = typeof RESPONSE_STYLE_PRESETS[number]; @@ -43,7 +45,7 @@ export const buildResponseStyleInstruction = ({ }; export const fetchResponseStyleInstruction = async (): Promise => { - const response = await fetch('/api/config/settings', { + const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' }, }); diff --git a/packages/ui/src/lib/runtime-auth.test.ts b/packages/ui/src/lib/runtime-auth.test.ts new file mode 100644 index 00000000..270acc21 --- /dev/null +++ b/packages/ui/src/lib/runtime-auth.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test'; +import { + buildRuntimeAuthHeaders, + clearRuntimeAuthCredentialProvider, + getRuntimeBearerTokenSync, + setRuntimeAuthCredentialProvider, + setRuntimeBearerToken, +} from './runtime-auth'; + +describe('runtime auth headers', () => { + test('does not add authorization by default', async () => { + clearRuntimeAuthCredentialProvider(); + const headers = await buildRuntimeAuthHeaders({ Accept: 'application/json' }); + + expect(headers.get('Accept')).toBe('application/json'); + expect(headers.has('Authorization')).toBe(false); + }); + + test('adds bearer token when configured', async () => { + try { + setRuntimeBearerToken('token-123'); + const headers = await buildRuntimeAuthHeaders(); + + expect(headers.get('Authorization')).toBe('Bearer token-123'); + } finally { + clearRuntimeAuthCredentialProvider(); + } + }); + + test('preserves explicit authorization header', async () => { + try { + setRuntimeAuthCredentialProvider(() => ({ type: 'bearer', token: 'runtime-token' })); + const headers = await buildRuntimeAuthHeaders({ Authorization: 'Bearer explicit-token' }); + + expect(headers.get('Authorization')).toBe('Bearer explicit-token'); + } finally { + clearRuntimeAuthCredentialProvider(); + } + }); + + test('falls back to injected desktop client token', async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + try { + clearRuntimeAuthCredentialProvider(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { __OPENCHAMBER_CLIENT_TOKEN__: ' injected-token ' }, + }); + + expect(getRuntimeBearerTokenSync()).toBe('injected-token'); + + const headers = await buildRuntimeAuthHeaders(); + expect(headers.get('Authorization')).toBe('Bearer injected-token'); + } finally { + clearRuntimeAuthCredentialProvider(); + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }); +}); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts new file mode 100644 index 00000000..ff0dbc90 --- /dev/null +++ b/packages/ui/src/lib/runtime-auth.ts @@ -0,0 +1,167 @@ +export type RuntimeAuthCredential = + | { type: 'bearer'; token: string } + | null; + +export type RuntimeAuthCredentialProvider = () => RuntimeAuthCredential | Promise; + +let credentialProvider: RuntimeAuthCredentialProvider = () => null; +let runtimeBearerToken = ''; +let runtimeUrlAuthToken = ''; +let runtimeUrlAuthTokenExpiresAt = 0; +let runtimeUrlAuthRefreshPromise: Promise | null = null; +let runtimeAuthGeneration = 0; + +const URL_AUTH_REFRESH_SKEW_MS = 10_000; + +const normalizeBearerToken = (token: string | null | undefined): string => { + if (typeof token !== 'string') return ''; + return token.trim(); +}; + +const readInjectedBearerToken = (): string => { + if (typeof window === 'undefined') return ''; + const injected = (window as typeof window & { __OPENCHAMBER_CLIENT_TOKEN__?: string }).__OPENCHAMBER_CLIENT_TOKEN__; + return normalizeBearerToken(injected); +}; + +const readInjectedApiBaseUrl = (): string => { + if (typeof window === 'undefined') return ''; + const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__; + return typeof injected === 'string' ? injected.trim() : ''; +}; + +const buildAuthUrl = (apiBaseUrl: string | null | undefined, path: string): string => { + const base = typeof apiBaseUrl === 'string' && apiBaseUrl.trim() + ? apiBaseUrl.trim() + : readInjectedApiBaseUrl(); + if (!base) return path; + try { + return new URL(path, `${base.replace(/\/+$/, '')}/`).toString(); + } catch { + return path; + } +}; + +const clearRuntimeUrlAuthToken = (): void => { + runtimeUrlAuthToken = ''; + runtimeUrlAuthTokenExpiresAt = 0; +}; + +const resetRuntimeAuthGeneration = (): void => { + runtimeAuthGeneration += 1; + runtimeUrlAuthRefreshPromise = null; + clearRuntimeUrlAuthToken(); +}; + +export const setRuntimeAuthCredentialProvider = (provider: RuntimeAuthCredentialProvider): void => { + runtimeBearerToken = ''; + resetRuntimeAuthGeneration(); + credentialProvider = provider; +}; + +export const clearRuntimeAuthCredentialProvider = (): void => { + runtimeBearerToken = ''; + resetRuntimeAuthGeneration(); + credentialProvider = () => null; +}; + +export const setRuntimeBearerToken = (token: string | null | undefined): void => { + const normalized = normalizeBearerToken(token); + runtimeBearerToken = normalized; + resetRuntimeAuthGeneration(); + credentialProvider = () => normalized ? { type: 'bearer', token: normalized } : null; +}; + +export const getRuntimeBearerTokenSync = (): string => runtimeBearerToken || readInjectedBearerToken(); + +export const setRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => { + const normalized = normalizeBearerToken(token); + if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) { + clearRuntimeUrlAuthToken(); + return; + } + runtimeUrlAuthToken = normalized; + runtimeUrlAuthTokenExpiresAt = expiresAt; +}; + +const readValidRuntimeUrlAuthTokenSync = (): string => { + if (!runtimeUrlAuthToken || runtimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) { + clearRuntimeUrlAuthToken(); + return ''; + } + return runtimeUrlAuthToken; +}; + +export const getRuntimeUrlAuthTokenSync = (): string => { + const token = readValidRuntimeUrlAuthTokenSync(); + if (!token && (getRuntimeBearerTokenSync() || typeof window !== 'undefined')) { + void refreshRuntimeUrlAuthToken().catch(() => {}); + } + return token; +}; + +export const getRuntimeAuthCredential = async (): Promise => { + const credential = await credentialProvider(); + const token = credential?.type === 'bearer' + ? normalizeBearerToken(credential.token) + : getRuntimeBearerTokenSync(); + return token ? { type: 'bearer', token } : null; +}; + +export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Promise => { + const existing = readValidRuntimeUrlAuthTokenSync(); + if (existing) return existing; + if (runtimeUrlAuthRefreshPromise) return runtimeUrlAuthRefreshPromise; + const generation = runtimeAuthGeneration; + + const refreshPromise = (async () => { + const credential = await getRuntimeAuthCredential(); + const headers = new Headers(); + if (credential?.type === 'bearer') { + headers.set('Authorization', `Bearer ${credential.token}`); + } + const response = await fetch(buildAuthUrl(apiBaseUrl, '/auth/url-token'), { + method: 'POST', + headers, + credentials: 'include', + }); + if (!response.ok) { + if (generation === runtimeAuthGeneration) { + clearRuntimeUrlAuthToken(); + } + throw new Error(`Failed to mint runtime URL auth token (${response.status})`); + } + const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null; + const token = typeof payload?.token === 'string' ? payload.token.trim() : ''; + const expiresAt = typeof payload?.expiresAt === 'number' ? payload.expiresAt : 0; + if (generation !== runtimeAuthGeneration) { + throw new Error('Runtime URL auth token response is stale'); + } + setRuntimeUrlAuthToken(token, expiresAt); + if (!runtimeUrlAuthToken) { + throw new Error('Runtime URL auth token response was invalid'); + } + return runtimeUrlAuthToken; + })(); + const trackedPromise = refreshPromise.finally(() => { + if (runtimeUrlAuthRefreshPromise === trackedPromise) { + runtimeUrlAuthRefreshPromise = null; + } + }); + runtimeUrlAuthRefreshPromise = trackedPromise; + + return runtimeUrlAuthRefreshPromise; +}; + +export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise => { + const next = new Headers(headers); + if (next.has('Authorization')) { + return next; + } + + const credential = await getRuntimeAuthCredential(); + if (credential?.type === 'bearer') { + next.set('Authorization', `Bearer ${credential.token}`); + } + return next; +}; diff --git a/packages/ui/src/lib/runtime-fetch.test.ts b/packages/ui/src/lib/runtime-fetch.test.ts new file mode 100644 index 00000000..73387b14 --- /dev/null +++ b/packages/ui/src/lib/runtime-fetch.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from 'bun:test'; +import { createOpencodeClient } from '@opencode-ai/sdk/v2'; +import { buildRuntimeFetchUrl, runtimeFetch } from './runtime-fetch'; +import { clearRuntimeAuthCredentialProvider, setRuntimeBearerToken } from './runtime-auth'; +import { configureRuntimeUrlResolver, getRuntimeUrlResolver, setRuntimeUrlResolver } from './runtime-url'; + +const originalFetch = globalThis.fetch; + +describe('buildRuntimeFetchUrl', () => { + test('preserves same-origin paths by default', () => { + expect(buildRuntimeFetchUrl('/api/config/settings')).toBe('/api/config/settings'); + expect(buildRuntimeFetchUrl('/auth/session')).toBe('/auth/session'); + expect(buildRuntimeFetchUrl('/health')).toBe('/health'); + }); + + test('resolves API/auth/health through configured runtime URL resolver', () => { + const previous = getRuntimeUrlResolver(); + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' }); + + expect(buildRuntimeFetchUrl('/api/config/settings')).toBe('https://api.example/api/config/settings'); + expect(buildRuntimeFetchUrl('/auth/session')).toBe('https://api.example/auth/session'); + expect(buildRuntimeFetchUrl('/health')).toBe('https://api.example/health'); + expect(buildRuntimeFetchUrl('/api/find/file', { query: 'x' })).toBe('https://api.example/api/find/file?query=x'); + } finally { + setRuntimeUrlResolver(previous); + } + }); + + test('rewrites current-origin absolute API URLs only', () => { + const previous = getRuntimeUrlResolver(); + const originalWindow = globalThis.window; + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' } }, + }); + + expect(buildRuntimeFetchUrl('openchamber-ui://app/api/config/settings')).toBe('https://api.example/api/config/settings'); + expect(buildRuntimeFetchUrl('https://external.example/api/config/settings')).toBe('https://external.example/api/config/settings'); + } finally { + setRuntimeUrlResolver(previous); + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + globalThis.fetch = originalFetch; + clearRuntimeAuthCredentialProvider(); + } + }); +}); + +describe('runtimeFetch transport contract', () => { + test('preserves bodies from actual SDK mutation requests on same-origin runtimes', async () => { + const previous = getRuntimeUrlResolver(); + const originalWindow = globalThis.window; + const calls: Array<{ url: string; method: string; body: string; headers: Headers }> = []; + + try { + configureRuntimeUrlResolver({}); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { location: { origin: 'https://app.example', href: 'https://app.example/' } }, + }); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + calls.push({ + url: request.url, + method: request.method, + body: await request.clone().text(), + headers: request.headers, + }); + return new Response(JSON.stringify({ ok: true, id: 'ses_1', time: { created: 1 } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + + const client = createOpencodeClient({ + baseUrl: 'https://app.example/api', + fetch: runtimeFetch, + }); + + await client.session.revert({ sessionID: 'ses_1', directory: '/repo', messageID: 'msg_1' }); + await client.session.shell({ + sessionID: 'ses_1', + directory: '/repo', + messageID: 'msg_2', + agent: 'build', + model: { providerID: 'anthropic', modelID: 'claude-sonnet' }, + command: 'ls', + }); + await client.session.update({ sessionID: 'ses_1', directory: '/repo', time: { archived: 123 } }); + await client.permission.reply({ requestID: 'perm_1', directory: '/repo', reply: 'once' }); + await client.question.reply({ requestID: 'q_1', directory: '/repo', answers: [['yes']] }); + await client.auth.set({ providerID: 'anthropic', auth: { type: 'api', key: 'secret' } }); + await client.provider.oauth.callback({ providerID: 'github-copilot', method: 0, code: 'oauth-code' }); + + expect(calls.map((call) => call.url)).toEqual([ + 'https://app.example/api/session/ses_1/revert?directory=%2Frepo', + 'https://app.example/api/session/ses_1/shell?directory=%2Frepo', + 'https://app.example/api/session/ses_1?directory=%2Frepo', + 'https://app.example/api/permission/perm_1/reply?directory=%2Frepo', + 'https://app.example/api/question/q_1/reply?directory=%2Frepo', + 'https://app.example/api/auth/anthropic', + 'https://app.example/api/provider/github-copilot/oauth/callback', + ]); + expect(calls.map((call) => call.method)).toEqual(['POST', 'POST', 'PATCH', 'POST', 'POST', 'PUT', 'POST']); + expect(calls.map((call) => call.headers.get('content-type'))).toEqual([ + 'application/json', + 'application/json', + 'application/json', + 'application/json', + 'application/json', + 'application/json', + 'application/json', + ]); + expect(calls.map((call) => JSON.parse(call.body))).toEqual([ + { messageID: 'msg_1' }, + { + messageID: 'msg_2', + agent: 'build', + model: { providerID: 'anthropic', modelID: 'claude-sonnet' }, + command: 'ls', + }, + { time: { archived: 123 } }, + { reply: 'once' }, + { answers: [['yes']] }, + { type: 'api', key: 'secret' }, + { method: 0, code: 'oauth-code' }, + ]); + } finally { + setRuntimeUrlResolver(previous); + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + globalThis.fetch = originalFetch; + clearRuntimeAuthCredentialProvider(); + } + }); + + test('preserves SDK-style Request method, JSON body, signal, path, query, and merges auth headers', async () => { + const previous = getRuntimeUrlResolver(); + const originalWindow = globalThis.window; + const controller = new AbortController(); + const calls: Array<{ input: Request; body: string }> = []; + + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example/base' }); + setRuntimeBearerToken('runtime-token'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { location: { origin: 'https://app.example', href: 'https://app.example/app' } }, + }); + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const request = input instanceof Request ? input : new Request(input); + calls.push({ input: request, body: await request.clone().text() }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof fetch; + + const request = new Request('https://app.example/api/session/abc/prompt_async?directory=%2Frepo&workspace=main', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-sdk-header': 'kept', + }, + body: JSON.stringify({ parts: [{ type: 'text', text: 'hello' }] }), + signal: controller.signal, + }); + + await runtimeFetch(request, { headers: { 'x-init-header': 'merged' } }); + + expect(calls).toHaveLength(1); + const captured = calls[0].input; + expect(captured.url).toBe('https://runtime.example/api/session/abc/prompt_async?directory=%2Frepo&workspace=main'); + expect(captured.method).toBe('POST'); + expect(captured.signal).toBe(controller.signal); + expect(captured.headers.get('content-type')).toBe('application/json'); + expect(captured.headers.get('x-sdk-header')).toBe('kept'); + expect(captured.headers.get('x-init-header')).toBe('merged'); + expect(captured.headers.get('authorization')).toBe('Bearer runtime-token'); + expect(calls[0].body).toBe(JSON.stringify({ parts: [{ type: 'text', text: 'hello' }] })); + } finally { + setRuntimeUrlResolver(previous); + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + } + }); + + test('does not replace an existing Authorization header', async () => { + const previous = getRuntimeUrlResolver(); + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example' }); + setRuntimeBearerToken('runtime-token'); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }); + return new Response(null, { status: 204 }); + }) as typeof fetch; + + await runtimeFetch('/api/path', { + headers: { Authorization: 'Bearer sdk-token' }, + }); + + expect(new Headers(calls[0].init?.headers).get('authorization')).toBe('Bearer sdk-token'); + } finally { + setRuntimeUrlResolver(previous); + globalThis.fetch = originalFetch; + clearRuntimeAuthCredentialProvider(); + } + }); + + test('resolves URLSearchParams query and auth for runtime asset fetches', async () => { + const previous = getRuntimeUrlResolver(); + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example' }); + setRuntimeBearerToken('runtime-token'); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }); + return new Response(new Blob(['icon']), { status: 200 }); + }) as typeof fetch; + + await runtimeFetch('/api/projects/project-1/icon', { + method: 'GET', + headers: { Accept: 'image/*' }, + query: new URLSearchParams({ v: '123', theme: 'dark', iconColor: '#fff' }), + }); + + expect(String(calls[0].input)).toBe('https://runtime.example/api/projects/project-1/icon?v=123&theme=dark&iconColor=%23fff'); + const headers = new Headers(calls[0].init?.headers); + expect(headers.get('accept')).toBe('image/*'); + expect(headers.get('authorization')).toBe('Bearer runtime-token'); + } finally { + setRuntimeUrlResolver(previous); + globalThis.fetch = originalFetch; + clearRuntimeAuthCredentialProvider(); + } + }); + + test('does not attach runtime auth to non-runtime absolute URLs', async () => { + const previous = getRuntimeUrlResolver(); + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example' }); + setRuntimeBearerToken('runtime-token'); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }); + return new Response(null, { status: 204 }); + }) as typeof fetch; + + await runtimeFetch('https://old-runtime.example/api/config/settings'); + + expect(String(calls[0].input)).toBe('https://old-runtime.example/api/config/settings'); + expect(new Headers(calls[0].init?.headers).has('authorization')).toBe(false); + } finally { + setRuntimeUrlResolver(previous); + globalThis.fetch = originalFetch; + clearRuntimeAuthCredentialProvider(); + } + }); + + test('attaches runtime auth to active runtime auth URLs', async () => { + const previous = getRuntimeUrlResolver(); + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example' }); + setRuntimeBearerToken('runtime-token'); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }); + return new Response(JSON.stringify({ authenticated: true }), { status: 200 }); + }) as typeof fetch; + + await runtimeFetch('https://runtime.example/auth/session'); + + expect(String(calls[0].input)).toBe('https://runtime.example/auth/session'); + expect(new Headers(calls[0].init?.headers).get('authorization')).toBe('Bearer runtime-token'); + } finally { + setRuntimeUrlResolver(previous); + globalThis.fetch = originalFetch; + clearRuntimeAuthCredentialProvider(); + } + }); +}); diff --git a/packages/ui/src/lib/runtime-fetch.ts b/packages/ui/src/lib/runtime-fetch.ts new file mode 100644 index 00000000..4fdac546 --- /dev/null +++ b/packages/ui/src/lib/runtime-fetch.ts @@ -0,0 +1,198 @@ +import { buildRuntimeAuthHeaders } from './runtime-auth'; +import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url'; + +export interface RuntimeFetchOptions extends RequestInit { + query?: RuntimeUrlQuery; +} + +const shouldResolveApiPath = (input: string): boolean => { + return input.startsWith('/api/') || input === '/api' || input.startsWith('/auth/') || input === '/auth' || input === '/health'; +}; + +const getCurrentOrigin = (): string => { + if (typeof window === 'undefined') return ''; + return window.location.origin || ''; +}; + +const isCurrentWindowUrl = (url: URL): boolean => { + if (typeof window === 'undefined') return false; + const currentOrigin = getCurrentOrigin(); + if (currentOrigin && url.origin === currentOrigin) return true; + try { + const current = new URL(window.location.href || currentOrigin); + return url.protocol === current.protocol && url.host === current.host; + } catch { + return false; + } +}; + +const isAbsoluteUrl = (value: string): boolean => /^[a-z][a-z\d+.-]*:\/\//i.test(value); + +const appendRuntimeQuery = (url: URL, query?: RuntimeUrlQuery): void => { + if (!query) return; + const entries = query instanceof URLSearchParams ? Array.from(query.entries()) : Object.entries(query); + for (const [key, value] of entries) { + if (value === null || value === undefined) continue; + url.searchParams.set(key, String(value)); + } +}; + +const isActiveRuntimeServiceUrl = (url: URL): boolean => { + try { + const apiBase = getRuntimeUrlResolver().api('/api'); + if (!/^[a-z][a-z\d+.-]*:\/\//i.test(apiBase)) return false; + const base = new URL(apiBase); + if (url.origin !== base.origin) return false; + return shouldResolveApiPath(url.pathname); + } catch { + return false; + } +}; + +const shouldResolveFetchInput = (input: string): boolean => { + if (shouldResolveApiPath(input)) return true; + if (!/^[a-z][a-z\d+.-]*:\/\//i.test(input)) return false; + try { + const url = new URL(input); + return isCurrentWindowUrl(url) && shouldResolveApiPath(url.pathname); + } catch { + return false; + } +}; + +const buildRuntimeFetchUrlFromAbsolute = (input: string, query?: RuntimeUrlQuery): string => { + try { + const url = new URL(input); + if (!isCurrentWindowUrl(url)) return input; + const rewritten = buildRuntimeFetchUrl(`${url.pathname}${url.search}`, query); + if (!isAbsoluteUrl(rewritten) && (url.protocol === 'http:' || url.protocol === 'https:')) { + appendRuntimeQuery(url, query); + return url.toString(); + } + return url.hash ? `${rewritten}${url.hash}` : rewritten; + } catch { + return input; + } +}; + +export const buildRuntimeFetchUrl = (input: string, query?: RuntimeUrlQuery): string => { + if (input === '/health') return getRuntimeUrlResolver().health(query); + if (input.startsWith('/auth/') || input === '/auth') return getRuntimeUrlResolver().auth(input, query); + if (shouldResolveApiPath(input)) return getRuntimeUrlResolver().api(input, query); + if (/^[a-z][a-z\d+.-]*:\/\//i.test(input)) return buildRuntimeFetchUrlFromAbsolute(input, query); + return input; +}; + +const shouldAttachRuntimeAuth = (input: string | URL | Request): boolean => { + const raw = input instanceof Request ? input.url : input.toString(); + if (!isAbsoluteUrl(raw)) { + return shouldResolveApiPath(raw); + } + + try { + return isActiveRuntimeServiceUrl(new URL(raw)); + } catch { + return false; + } +}; + +const mergeHeaders = async (inputHeaders?: HeadersInit, initHeaders?: HeadersInit, attachAuth = true): Promise => { + const headers = new Headers(inputHeaders); + if (initHeaders) { + new Headers(initHeaders).forEach((value, key) => headers.set(key, value)); + } + if (!attachAuth) { + return headers; + } + return buildRuntimeAuthHeaders(headers); +}; + +const resolveRuntimeFetchInput = (input: string | URL | Request, query?: RuntimeUrlQuery): string | URL | Request => { + if (typeof input === 'string') { + return buildRuntimeFetchUrl(input, query); + } + + if (input instanceof URL) { + return buildRuntimeFetchUrl(input.toString(), query); + } + + const target = buildRuntimeFetchUrl(input.url, query); + return target === input.url ? input : new Request(target, input); +}; + +export const runtimeFetch = async (input: string | URL | Request, init: RuntimeFetchOptions = {}): Promise => { + const { query, ...requestInit } = init; + const resolvedInput = resolveRuntimeFetchInput(input, query); + const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined; + const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput)); + + if (resolvedInput instanceof Request) { + return fetch(new Request(resolvedInput, { ...requestInit, headers })); + } + + return fetch(resolvedInput, { + ...requestInit, + headers, + }); +}; + +let runtimeFetchBridgeInstalled = false; + +export const installRuntimeFetchBridge = (): void => { + if (runtimeFetchBridgeInstalled || typeof window === 'undefined') return; + runtimeFetchBridgeInstalled = true; + + const nativeFetch = window.fetch.bind(window); + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + if (typeof input === 'string') { + if (!shouldResolveFetchInput(input)) { + try { + const url = new URL(input); + if (isActiveRuntimeServiceUrl(url)) { + const headers = await mergeHeaders(undefined, init?.headers); + return nativeFetch(input, { ...init, headers }); + } + } catch { + // Non-URL fetch inputs should fall through unchanged. + } + return nativeFetch(input, init); + } + const headers = await mergeHeaders(undefined, init?.headers); + return nativeFetch(buildRuntimeFetchUrl(input), { ...init, headers }); + } + + if (input instanceof URL) { + const raw = input.toString(); + if (!shouldResolveFetchInput(raw)) { + if (isActiveRuntimeServiceUrl(input)) { + const headers = await mergeHeaders(undefined, init?.headers); + return nativeFetch(input, { ...init, headers }); + } + return nativeFetch(input, init); + } + const headers = await mergeHeaders(undefined, init?.headers); + return nativeFetch(buildRuntimeFetchUrl(raw), { ...init, headers }); + } + + if (input instanceof Request) { + if (!shouldResolveFetchInput(input.url)) { + try { + const url = new URL(input.url); + if (isActiveRuntimeServiceUrl(url)) { + const headers = await mergeHeaders(input.headers, init?.headers); + return nativeFetch(new Request(input, { ...init, headers })); + } + } catch { + // Non-URL request inputs should fall through unchanged. + } + return nativeFetch(input, init); + } + const headers = await mergeHeaders(input.headers, init?.headers); + const target = buildRuntimeFetchUrl(input.url); + const request = target === input.url ? input : new Request(target, input); + return nativeFetch(new Request(request, { ...init, headers })); + } + + return nativeFetch(input, init); + }; +}; diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts new file mode 100644 index 00000000..6d8084d6 --- /dev/null +++ b/packages/ui/src/lib/runtime-switch.ts @@ -0,0 +1,103 @@ +import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken } from '@/lib/runtime-auth'; +import { configureRuntimeUrlResolver } from '@/lib/runtime-url'; + +export type RuntimeEndpointChangedDetail = { + apiBaseUrl: string; + previousApiBaseUrl: string; + runtimeKey: string; + previousRuntimeKey: string; +}; + +const RUNTIME_ENDPOINT_CHANGED_EVENT = 'openchamber:runtime-endpoint-changed'; + +let activeApiBaseUrl = ''; +let activeRuntimeKey = ''; + +const normalizeRuntimeUrlKey = (value: string): string => { + try { + const url = new URL(value); + url.hash = ''; + url.search = ''; + url.pathname = url.pathname.replace(/\/+$/, '') || '/'; + return `url:${url.toString().replace(/\/+$/, '')}`; + } catch { + return `url:${value.trim().replace(/\/+$/, '') || 'default'}`; + } +}; + +const readInjectedApiBaseUrl = (): string => { + if (typeof window === 'undefined') return ''; + const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__; + return typeof injected === 'string' ? injected.trim() : ''; +}; + +const readInjectedLocalOrigin = (): 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 => { + if (!left || !right) return false; + try { + return new URL(left).origin === new URL(right).origin; + } catch { + return false; + } +}; + +export const getRuntimeApiBaseUrl = (): string => activeApiBaseUrl || readInjectedApiBaseUrl(); +export const getRuntimeKey = (): string => { + if (activeRuntimeKey) return activeRuntimeKey; + const apiBaseUrl = getRuntimeApiBaseUrl(); + if (sameOrigin(apiBaseUrl, readInjectedLocalOrigin())) return 'local'; + return normalizeRuntimeUrlKey(apiBaseUrl); +}; + +export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; runtimeKey?: string | null } = {}): void => { + if (activeApiBaseUrl || activeRuntimeKey) { + return; + } + + const apiBaseUrl = options.apiBaseUrl?.trim() || readInjectedApiBaseUrl(); + if (!apiBaseUrl) { + return; + } + + activeApiBaseUrl = apiBaseUrl; + activeRuntimeKey = options.runtimeKey?.trim() || (sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) ? 'local' : normalizeRuntimeUrlKey(apiBaseUrl)); +}; + +export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null }): void => { + const apiBaseUrl = options.apiBaseUrl.trim(); + const previousApiBaseUrl = getRuntimeApiBaseUrl(); + const previousRuntimeKey = getRuntimeKey(); + const runtimeKey = options.runtimeKey?.trim() || normalizeRuntimeUrlKey(apiBaseUrl); + activeApiBaseUrl = apiBaseUrl; + activeRuntimeKey = runtimeKey; + if (typeof window !== 'undefined') { + const runtimeWindow = window as typeof window & { + __OPENCHAMBER_API_BASE_URL__?: string; + __OPENCHAMBER_CLIENT_TOKEN__?: string; + }; + runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl; + runtimeWindow.__OPENCHAMBER_CLIENT_TOKEN__ = options.clientToken || undefined; + } + configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl }); + setRuntimeBearerToken(options.clientToken || null); + void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {}); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(RUNTIME_ENDPOINT_CHANGED_EVENT, { + detail: { apiBaseUrl, previousApiBaseUrl, runtimeKey, previousRuntimeKey }, + })); + } +}; + +export const subscribeRuntimeEndpointChanged = (callback: (detail: RuntimeEndpointChangedDetail) => void): (() => void) => { + if (typeof window === 'undefined') return () => {}; + const listener = (event: Event) => { + callback((event as CustomEvent).detail); + }; + window.addEventListener(RUNTIME_ENDPOINT_CHANGED_EVENT, listener); + return () => window.removeEventListener(RUNTIME_ENDPOINT_CHANGED_EVENT, listener); +}; diff --git a/packages/ui/src/lib/runtime-url.test.ts b/packages/ui/src/lib/runtime-url.test.ts new file mode 100644 index 00000000..ba38e790 --- /dev/null +++ b/packages/ui/src/lib/runtime-url.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; +import { + configureRuntimeUrlResolver, + createRuntimeUrlResolver, + getRuntimeUrlResolver, + setRuntimeUrlResolver, +} from './runtime-url'; +import { setRuntimeBearerToken, setRuntimeUrlAuthToken } from './runtime-auth'; + +describe('createRuntimeUrlResolver', () => { + test('preserves relative same-origin URLs by default', () => { + const urls = createRuntimeUrlResolver({ currentHref: () => 'http://127.0.0.1:3000/app' }); + + expect(urls.api('/api/config/settings')).toBe('/api/config/settings'); + expect(urls.health()).toBe('/health'); + expect(urls.rawFile('/tmp/a b.txt')).toBe('/api/fs/raw?path=%2Ftmp%2Fa+b.txt'); + }); + + test('builds absolute API URLs when an API base URL is configured', () => { + const urls = createRuntimeUrlResolver({ apiBaseUrl: 'https://server.example/base/' }); + + expect(urls.api('/api/config/settings')).toBe('https://server.example/api/config/settings'); + expect(urls.auth('/auth/device', { next: '/app' })).toBe('https://server.example/auth/device?next=%2Fapp'); + expect(urls.health({ probe: true })).toBe('https://server.example/health?probe=true'); + }); + + test('uses realtime base URL for SSE and WebSocket URLs', () => { + const urls = createRuntimeUrlResolver({ + apiBaseUrl: 'https://api.example', + realtimeBaseUrl: 'https://realtime.example/root', + }); + + expect(urls.sse('/api/openchamber/events')).toBe('https://realtime.example/api/openchamber/events'); + expect(urls.websocket('/api/global/event/ws', { lastEventId: 'evt-1' })).toBe( + 'wss://realtime.example/api/global/event/ws?lastEventId=evt-1', + ); + }); + + test('converts absolute HTTP URLs to WebSocket URLs', () => { + const urls = createRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' }); + + expect(urls.websocket('http://remote.example/api/terminal/ws')).toBe('ws://remote.example/api/terminal/ws'); + expect(urls.websocket('https://remote.example/api/global/event/ws', { lastEventId: '2' })).toBe( + 'wss://remote.example/api/global/event/ws?lastEventId=2', + ); + expect(urls.websocket('wss://remote.example/api/terminal/ws')).toBe('wss://remote.example/api/terminal/ws'); + }); + + test('derives WebSocket origin from the current page for default relative URLs', () => { + const urls = createRuntimeUrlResolver({ currentHref: () => 'http://localhost:5173/mobile.html' }); + + expect(urls.websocket('/api/terminal/ws')).toBe('ws://localhost:5173/api/terminal/ws'); + }); + + test('allows runtime-wide resolver configuration', () => { + const previous = getRuntimeUrlResolver(); + try { + const configured = configureRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' }); + expect(getRuntimeUrlResolver()).toBe(configured); + expect(getRuntimeUrlResolver().api('/api/version')).toBe('https://api.example/api/version'); + } finally { + setRuntimeUrlResolver(previous); + } + }); + + test('adds short-lived URL auth query to realtime and authenticated asset URLs only', () => { + setRuntimeBearerToken('oc_client_secret'); + setRuntimeUrlAuthToken('oc_url_secret', Date.now() + 60_000); + try { + const urls = createRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' }); + + expect(urls.api('/api/config/settings')).toBe('https://api.example/api/config/settings'); + expect(urls.authenticatedAsset('/api/projects/p1/icon', { v: 123 })).toBe( + 'https://api.example/api/projects/p1/icon?v=123&oc_url_token=oc_url_secret', + ); + expect(urls.sse('/api/openchamber/events')).toBe( + 'https://api.example/api/openchamber/events?oc_url_token=oc_url_secret', + ); + expect(urls.websocket('/api/global/event/ws', { lastEventId: 'evt-1' })).toBe( + 'wss://api.example/api/global/event/ws?lastEventId=evt-1&oc_url_token=oc_url_secret', + ); + } finally { + setRuntimeBearerToken(null); + } + }); + + test('does not put the long-lived client token in URLs', () => { + setRuntimeBearerToken('oc_client_secret'); + try { + const urls = createRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' }); + expect(urls.sse('/api/openchamber/events')).toBe('https://api.example/api/openchamber/events'); + expect(urls.websocket('/api/global/event/ws')).toBe('wss://api.example/api/global/event/ws'); + expect(urls.authenticatedAsset('/api/projects/p1/icon')).toBe('https://api.example/api/projects/p1/icon'); + } finally { + setRuntimeBearerToken(null); + } + }); +}); diff --git a/packages/ui/src/lib/runtime-url.ts b/packages/ui/src/lib/runtime-url.ts new file mode 100644 index 00000000..d2675b81 --- /dev/null +++ b/packages/ui/src/lib/runtime-url.ts @@ -0,0 +1,140 @@ +import { getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth'; + +type QueryValue = string | number | boolean | null | undefined; + +export type RuntimeUrlQuery = Record | URLSearchParams; + +export interface RuntimeUrlConfig { + apiBaseUrl?: string | null; + realtimeBaseUrl?: string | null; + currentHref?: () => string; +} + +export interface RuntimeUrlResolver { + api(path: string, query?: RuntimeUrlQuery): string; + authenticatedAsset(path: string, query?: RuntimeUrlQuery): string; + auth(path: string, query?: RuntimeUrlQuery): string; + health(query?: RuntimeUrlQuery): string; + rawFile(path: string, options?: { download?: boolean }): string; + sse(path: string, query?: RuntimeUrlQuery): string; + websocket(path: string, query?: RuntimeUrlQuery): string; +} + +const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i; + +const normalizePath = (path: string): string => { + const trimmed = path.trim(); + if (!trimmed) return '/'; + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +}; + +const normalizeBaseUrl = (value: string | null | undefined): string => { + if (typeof value !== 'string') return ''; + return value.trim().replace(/\/+$/, ''); +}; + +const currentHref = (config: RuntimeUrlConfig): string => { + const configured = config.currentHref?.(); + if (configured) return configured; + if (typeof window !== 'undefined') { + return window.location.href || window.location.origin; + } + return ''; +}; + +const appendQuery = (url: URL, query?: RuntimeUrlQuery): void => { + if (!query) return; + + const entries = query instanceof URLSearchParams + ? Array.from(query.entries()) + : Object.entries(query); + + for (const [key, value] of entries) { + if (value === null || value === undefined) continue; + url.searchParams.set(key, String(value)); + } +}; + +const appendRelativeQuery = (path: string, query?: RuntimeUrlQuery): string => { + if (!query) return path; + const params = new URLSearchParams(); + appendQuery({ searchParams: params } as URL, query); + const serialized = params.toString(); + if (!serialized) return path; + return path.includes('?') ? `${path}&${serialized}` : `${path}?${serialized}`; +}; + +const buildHttpUrl = (baseUrl: string, path: string, query?: RuntimeUrlQuery): string => { + if (ABSOLUTE_URL_PATTERN.test(path)) { + const url = new URL(path); + appendQuery(url, query); + return url.toString(); + } + + const normalizedPath = normalizePath(path); + if (!baseUrl) { + return appendRelativeQuery(normalizedPath, query); + } + + const url = new URL(normalizedPath, `${baseUrl}/`); + appendQuery(url, query); + return url.toString(); +}; + +const withUrlAuth = (urlValue: string): string => { + const token = getRuntimeUrlAuthTokenSync(); + if (!token) return urlValue; + + if (ABSOLUTE_URL_PATTERN.test(urlValue)) { + const url = new URL(urlValue); + url.searchParams.set('oc_url_token', token); + return url.toString(); + } + + const separator = urlValue.includes('?') ? '&' : '?'; + return `${urlValue}${separator}oc_url_token=${encodeURIComponent(token)}`; +}; + +const toWebSocketUrl = (candidate: string, config: RuntimeUrlConfig): string => { + const url = ABSOLUTE_URL_PATTERN.test(candidate) + ? new URL(candidate) + : new URL(candidate, currentHref(config)); + if (url.protocol === 'ws:' || url.protocol === 'wss:') { + return url.toString(); + } + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.toString(); +}; + +export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): RuntimeUrlResolver => { + const apiBaseUrl = normalizeBaseUrl(config.apiBaseUrl); + const realtimeBaseUrl = normalizeBaseUrl(config.realtimeBaseUrl) || apiBaseUrl; + + const http = (path: string, query?: RuntimeUrlQuery): string => buildHttpUrl(apiBaseUrl, path, query); + const realtime = (path: string, query?: RuntimeUrlQuery): string => buildHttpUrl(realtimeBaseUrl, path, query); + + return { + api: http, + authenticatedAsset: (path, query) => withUrlAuth(http(path, query)), + auth: http, + health: (query) => http('/health', query), + rawFile: (path, options) => http('/api/fs/raw', { path, download: options?.download === true ? true : undefined }), + sse: (path, query) => withUrlAuth(realtime(path, query)), + websocket: (path, query) => toWebSocketUrl(withUrlAuth(realtime(path, query)), config), + }; +}; + +let activeRuntimeUrlResolver = createRuntimeUrlResolver(); + +export const getRuntimeUrlResolver = (): RuntimeUrlResolver => activeRuntimeUrlResolver; + +export const setRuntimeUrlResolver = (resolver: RuntimeUrlResolver): void => { + activeRuntimeUrlResolver = resolver; +}; + +export const configureRuntimeUrlResolver = (config: RuntimeUrlConfig): RuntimeUrlResolver => { + activeRuntimeUrlResolver = createRuntimeUrlResolver(config); + return activeRuntimeUrlResolver; +}; + +export const runtimeUrl = activeRuntimeUrlResolver; diff --git a/packages/ui/src/lib/scheduledTasksApi.ts b/packages/ui/src/lib/scheduledTasksApi.ts index 1e6f0d96..578e2bd7 100644 --- a/packages/ui/src/lib/scheduledTasksApi.ts +++ b/packages/ui/src/lib/scheduledTasksApi.ts @@ -1,3 +1,5 @@ +import { runtimeFetch } from './runtime-fetch'; + export type ScheduledTaskStatus = 'idle' | 'running' | 'success' | 'error'; export type ScheduledTask = { @@ -54,7 +56,7 @@ const ensureProjectID = (projectID: string): string => { export const fetchScheduledTasks = async (projectID: string): Promise => { const safeProjectID = ensureProjectID(projectID); - const response = await fetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks`); + const response = await runtimeFetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks`); if (!response.ok) { throw new Error(await parseErrorMessage(response, 'Failed to load scheduled tasks')); } @@ -67,7 +69,7 @@ export const fetchScheduledTasks = async (projectID: string): Promise): Promise => { const safeProjectID = ensureProjectID(projectID); - const response = await fetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks`, { + const response = await runtimeFetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks`, { method: 'PUT', headers: { 'content-type': 'application/json', @@ -88,7 +90,7 @@ export const upsertScheduledTask = async (projectID: string, task: Partial => { const safeProjectID = ensureProjectID(projectID); const safeTaskID = ensureProjectID(taskID); - const response = await fetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}`, { + const response = await runtimeFetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}`, { method: 'DELETE', headers: { accept: 'application/json', @@ -107,7 +109,7 @@ export const deleteScheduledTask = async (projectID: string, taskID: string): Pr export const runScheduledTaskNow = async (projectID: string, taskID: string): Promise<{ sessionId?: string }> => { const safeProjectID = ensureProjectID(projectID); const safeTaskID = ensureProjectID(taskID); - const response = await fetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}/run`, { + const response = await runtimeFetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}/run`, { method: 'POST', headers: { accept: 'application/json', diff --git a/packages/ui/src/lib/server-compatibility.test.ts b/packages/ui/src/lib/server-compatibility.test.ts new file mode 100644 index 00000000..2cd85e09 --- /dev/null +++ b/packages/ui/src/lib/server-compatibility.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test'; +import { evaluateServerCompatibility, REQUIRED_SERVER_CAPABILITIES } from './server-compatibility'; + +const compatiblePayload = () => ({ + status: 'ok', + openchamberVersion: '1.10.4', + runtime: 'web', + compatibility: { + apiVersion: 1, + minClientApiVersion: 1, + capabilities: [...REQUIRED_SERVER_CAPABILITIES], + }, +}); + +describe('evaluateServerCompatibility', () => { + test('accepts a compatible server', () => { + const result = evaluateServerCompatibility(compatiblePayload()); + + expect(result.status).toBe('compatible'); + expect(result.openchamberVersion).toBe('1.10.4'); + expect(result.runtime).toBe('web'); + }); + + test('rejects invalid compatibility payloads', () => { + expect(evaluateServerCompatibility({ status: 'ok' }).status).toBe('invalid-response'); + expect(evaluateServerCompatibility(null).status).toBe('invalid-response'); + }); + + test('detects old servers and old clients', () => { + expect(evaluateServerCompatibility({ + ...compatiblePayload(), + compatibility: { ...compatiblePayload().compatibility, apiVersion: 1 }, + }, { clientApiVersion: 2 }).status).toBe('server-too-old'); + + expect(evaluateServerCompatibility({ + ...compatiblePayload(), + compatibility: { ...compatiblePayload().compatibility, minClientApiVersion: 2 }, + }, { clientApiVersion: 1 }).status).toBe('client-too-old'); + }); + + test('detects missing required capabilities', () => { + const result = evaluateServerCompatibility({ + ...compatiblePayload(), + compatibility: { + ...compatiblePayload().compatibility, + capabilities: ['api.health.v1'], + }, + }); + + expect(result.status).toBe('missing-capability'); + expect(result.missingCapabilities).toContain('realtime.sse.v1'); + }); +}); diff --git a/packages/ui/src/lib/server-compatibility.ts b/packages/ui/src/lib/server-compatibility.ts new file mode 100644 index 00000000..86d33a7e --- /dev/null +++ b/packages/ui/src/lib/server-compatibility.ts @@ -0,0 +1,159 @@ +import { runtimeFetch } from './runtime-fetch'; + +export const OPENCHAMBER_CLIENT_API_VERSION = 1; + +export const REQUIRED_SERVER_CAPABILITIES = [ + 'api.health.v1', + 'api.runtime-url.v1', + 'api.raw-file.v1', + 'realtime.sse.v1', + 'realtime.websocket.global-events.v1', +] as const; + +export type ServerCompatibilityStatus = + | 'compatible' + | 'auth-required' + | 'unreachable' + | 'invalid-response' + | 'server-too-old' + | 'client-too-old' + | 'missing-capability'; + +export interface ServerCompatibilityPayload { + status?: unknown; + openchamberVersion?: unknown; + runtime?: unknown; + compatibility?: { + apiVersion?: unknown; + minClientApiVersion?: unknown; + capabilities?: unknown; + } | null; +} + +export interface ServerCompatibilityResult { + status: ServerCompatibilityStatus; + openchamberVersion: string | null; + runtime: string | null; + apiVersion: number | null; + minClientApiVersion: number | null; + missingCapabilities: string[]; + requiredCapabilities: string[]; + message: string; +} + +const parsePositiveInteger = (value: unknown): number | null => { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return null; + return value; +}; + +const parseString = (value: unknown): string | null => { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +}; + +const parseCapabilities = (value: unknown): Set => { + if (!Array.isArray(value)) return new Set(); + return new Set(value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)); +}; + +export const evaluateServerCompatibility = ( + payload: ServerCompatibilityPayload | null | undefined, + options: { + clientApiVersion?: number; + requiredCapabilities?: readonly string[]; + } = {}, +): ServerCompatibilityResult => { + const clientApiVersion = options.clientApiVersion ?? OPENCHAMBER_CLIENT_API_VERSION; + const requiredCapabilities = [...(options.requiredCapabilities ?? REQUIRED_SERVER_CAPABILITIES)]; + const compatibility = payload?.compatibility ?? null; + const apiVersion = parsePositiveInteger(compatibility?.apiVersion); + const minClientApiVersion = parsePositiveInteger(compatibility?.minClientApiVersion); + const openchamberVersion = parseString(payload?.openchamberVersion); + const runtime = parseString(payload?.runtime); + + const base = { + openchamberVersion, + runtime, + apiVersion, + minClientApiVersion, + missingCapabilities: [] as string[], + requiredCapabilities, + }; + + if (!payload || payload.status !== 'ok' || !compatibility || !apiVersion || !minClientApiVersion) { + return { + ...base, + status: 'invalid-response', + message: 'Server did not return OpenChamber compatibility metadata.', + }; + } + + if (apiVersion < clientApiVersion) { + return { + ...base, + status: 'server-too-old', + message: `Server API version ${apiVersion} is older than required client API version ${clientApiVersion}.`, + }; + } + + if (minClientApiVersion > clientApiVersion) { + return { + ...base, + status: 'client-too-old', + message: `Server requires client API version ${minClientApiVersion}, but this client supports ${clientApiVersion}.`, + }; + } + + const capabilities = parseCapabilities(compatibility.capabilities); + const missingCapabilities = requiredCapabilities.filter((capability) => !capabilities.has(capability)); + if (missingCapabilities.length > 0) { + return { + ...base, + status: 'missing-capability', + missingCapabilities, + message: `Server is missing required capabilities: ${missingCapabilities.join(', ')}.`, + }; + } + + return { + ...base, + status: 'compatible', + message: 'Server is compatible.', + }; +}; + +export const checkServerCompatibility = async (): Promise => { + let response: Response; + try { + response = await runtimeFetch('/api/version', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + } catch (error) { + return { + status: 'unreachable', + openchamberVersion: null, + runtime: null, + apiVersion: null, + minClientApiVersion: null, + missingCapabilities: [], + requiredCapabilities: [...REQUIRED_SERVER_CAPABILITIES], + message: error instanceof Error ? error.message : 'Server is unreachable.', + }; + } + + if (response.status === 401 || response.status === 403) { + return { + status: 'auth-required', + openchamberVersion: null, + runtime: null, + apiVersion: null, + minClientApiVersion: null, + missingCapabilities: [], + requiredCapabilities: [...REQUIRED_SERVER_CAPABILITIES], + message: 'Server requires authentication.', + }; + } + + const payload = await response.json().catch(() => null) as ServerCompatibilityPayload | null; + return evaluateServerCompatibility(payload); +}; diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index 33229cc0..65fef558 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -81,9 +81,9 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ slug: 'remote-instances', title: 'Remote Instances', group: 'projects', - kind: 'split', + kind: 'single', keywords: ['ssh', 'remote', 'instances', 'tunnels', 'forwarding', 'connection'], - isAvailable: (ctx) => ctx.isDesktop && !ctx.isWeb && !ctx.isVSCode, + isAvailable: (ctx) => !ctx.isVSCode, }, { slug: 'providers', diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 64c24f4d..144d7c94 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -1,3 +1,6 @@ +import { getRuntimeUrlResolver } from './runtime-url'; +import { runtimeFetch } from './runtime-fetch'; + export interface TerminalWebSocketDescriptor { path: string; v?: number; @@ -87,23 +90,7 @@ const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const normalizeWebSocketPath = (pathValue: string): string => { - if (/^wss?:\/\//i.test(pathValue)) { - return pathValue; - } - - if (/^https?:\/\//i.test(pathValue)) { - const url = new URL(pathValue); - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; - return url.toString(); - } - - if (typeof window === 'undefined') { - return ''; - } - - const normalizedPath = pathValue.startsWith('/') ? pathValue : `/${pathValue}`; - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${protocol}//${window.location.host}${normalizedPath}`; + return getRuntimeUrlResolver().websocket(pathValue); }; const encodeControlFrame = (payload: TerminalControlMessage): Uint8Array => { @@ -758,7 +745,7 @@ const applyTerminalTransportCapabilities = (capabilities: TerminalSession['capab }; const sendTerminalInputHttp = async (sessionId: string, data: string): Promise => { - const response = await fetch(`/api/terminal/${sessionId}/input`, { + const response = await runtimeFetch(`/api/terminal/${sessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: data, @@ -771,7 +758,7 @@ const sendTerminalInputHttp = async (sessionId: string, data: string): Promise { - const response = await fetch('/api/terminal/create', { + const response = await runtimeFetch('/api/terminal/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -869,7 +856,7 @@ const connectTerminalStreamViaSse = ( return; } - eventSource = new EventSource(`/api/terminal/${sessionId}/stream`); + eventSource = new EventSource(getRuntimeUrlResolver().sse(`/api/terminal/${sessionId}/stream`)); let opened = false; connectionTimeoutId = setTimeout(() => { @@ -960,7 +947,7 @@ export async function resizeTerminal( cols: number, rows: number ): Promise { - const response = await fetch(`/api/terminal/${sessionId}/resize`, { + const response = await runtimeFetch(`/api/terminal/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols, rows }), @@ -975,7 +962,7 @@ export async function resizeTerminal( export async function closeTerminal(sessionId: string): Promise { getTerminalTransportGlobalState().manager?.unbindSession(sessionId); - const response = await fetch(`/api/terminal/${sessionId}`, { + const response = await runtimeFetch(`/api/terminal/${sessionId}`, { method: 'DELETE', }); @@ -991,7 +978,7 @@ export async function restartTerminalSession( ): Promise { getTerminalTransportGlobalState().manager?.unbindSession(currentSessionId); - const response = await fetch(`/api/terminal/${currentSessionId}/restart`, { + const response = await runtimeFetch(`/api/terminal/${currentSessionId}/restart`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1015,7 +1002,7 @@ export async function forceKillTerminal(options: { sessionId?: string; cwd?: string; }): Promise { - const response = await fetch('/api/terminal/force-kill', { + const response = await runtimeFetch('/api/terminal/force-kill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), diff --git a/packages/ui/src/lib/terminalPreview.ts b/packages/ui/src/lib/terminalPreview.ts index b0844956..9897a215 100644 --- a/packages/ui/src/lib/terminalPreview.ts +++ b/packages/ui/src/lib/terminalPreview.ts @@ -1,3 +1,5 @@ +import { runtimeFetch } from '@/lib/runtime-fetch'; + const ANSI_ESCAPE_PREFIX = String.fromCharCode(27); const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g'); const LOOPBACK_URL_PATTERN = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[(?:::1|::)\])(?::\d{2,5})?(?:\/[^\s<>'"`]*)?)/gi; @@ -94,7 +96,7 @@ export const isTerminalPreviewUrlAvailable = async (url: string, timeoutMs = 150 const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch('/api/system/probe-url', { + const response = await runtimeFetch('/api/system/probe-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: parsed.toString() }), diff --git a/packages/ui/src/lib/voice/audioStreamService.ts b/packages/ui/src/lib/voice/audioStreamService.ts index 6e2fa50e..36b6602d 100644 --- a/packages/ui/src/lib/voice/audioStreamService.ts +++ b/packages/ui/src/lib/voice/audioStreamService.ts @@ -18,6 +18,8 @@ * ``` */ +import { runtimeFetch } from '@/lib/runtime-fetch'; + export type SpeechResultCallback = (text: string, isFinal: boolean) => void; export type ErrorCallback = (error: string) => void; @@ -352,7 +354,7 @@ class AudioStreamService { headers['X-Language'] = baseLang; } - const response = await fetch('/api/stt/transcribe', { + const response = await runtimeFetch('/api/stt/transcribe', { method: 'POST', headers, body: blob, diff --git a/packages/ui/src/lib/voice/summarize.ts b/packages/ui/src/lib/voice/summarize.ts index 6b6a55e2..d37eaa53 100644 --- a/packages/ui/src/lib/voice/summarize.ts +++ b/packages/ui/src/lib/voice/summarize.ts @@ -6,18 +6,7 @@ */ import { useConfigStore } from '@/stores/useConfigStore'; - -const resolveSummarizeUrl = (): string => { - if (typeof window === 'undefined') { - return '/api/text/summarize'; - } - - const desktopServer = (window as typeof window & { - __OPENCHAMBER_DESKTOP_SERVER__?: { origin: string }; - }).__OPENCHAMBER_DESKTOP_SERVER__; - const baseOrigin = desktopServer?.origin || window.location.origin; - return new URL('/api/text/summarize', baseOrigin).toString(); -}; +import { runtimeFetch } from '@/lib/runtime-fetch'; /** * Summarize text using the server-side zen API endpoint @@ -52,7 +41,7 @@ export async function summarizeText( } try { - const response = await fetch(resolveSummarizeUrl(), { + const response = await runtimeFetch('/api/text/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index a89a6962..591f2715 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -457,7 +457,7 @@ export async function createWorktreeSessionForNewBranch( ensureRemoteUrl?: string; createdFromBranch?: string; } -): Promise<{ id: string; branch: string } | null> { +): Promise<{ id: string; branch: string; path: string } | null> { if (isCreatingWorktreeSession) { return null; } @@ -523,7 +523,7 @@ export async function createWorktreeSessionForNewBranch( initializeSessionForWorktree(session.id, createdMetadata); - return { id: session.id, branch: metadata.branch || base }; + return { id: session.id, branch: metadata.branch || base, path: metadata.path }; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create worktree session'; toast.error('Failed to create worktree', { description: message }); @@ -552,7 +552,7 @@ export async function createWorktreeSessionForNewBranchExact( ensureRemoteUrl?: string; createdFromBranch?: string; } -): Promise<{ id: string; branch: string } | null> { +): Promise<{ id: string; branch: string; path: string } | null> { return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { kind: options?.kind, worktreeName: options?.worktreeName, diff --git a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts index a22ada72..49ad74ed 100644 --- a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts +++ b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts @@ -1,12 +1,6 @@ import * as gitHttp from '@/lib/gitApiHttp'; -import type { RuntimeAPIs } from '@/lib/api/types'; import type { GitWorktreeBootstrapStatus } from '@/lib/api/types'; - -declare global { - interface Window { - __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs; - } -} +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; type WorktreeBootstrapState = GitWorktreeBootstrapStatus; @@ -21,7 +15,7 @@ const waiters = new Map>(); const getKey = (directory: string): string => normalizePath(directory); const getGitWorktreeBootstrapStatus = async (directory: string): Promise => { - const runtimeGit = typeof window !== 'undefined' ? window.__OPENCHAMBER_RUNTIME_APIS__?.git : undefined; + const runtimeGit = getRegisteredRuntimeAPIs()?.git; if (runtimeGit?.worktree?.bootstrapStatus) { return runtimeGit.worktree.bootstrapStatus(directory); } diff --git a/packages/ui/src/stores/fileStore.ts b/packages/ui/src/stores/fileStore.ts index d863f507..05b9e191 100644 --- a/packages/ui/src/stores/fileStore.ts +++ b/packages/ui/src/stores/fileStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { devtools, persist, createJSONStorage } from "zustand/middleware"; import type { AttachedFile } from "./types/sessionTypes"; import { getSafeStorage } from "./utils/safeStorage"; +import { runtimeFetch } from "@/lib/runtime-fetch"; interface FileState { attachedFiles: AttachedFile[]; @@ -103,7 +104,7 @@ const toFileUrl = (inputPath: string): string => { }; const readRawFileAsDataUrl = async (absolutePath: string): Promise => { - const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(absolutePath)}`); + const response = await runtimeFetch("/api/fs/raw", { query: { path: absolutePath } }); if (!response.ok) { throw new Error(`Failed to read raw file: ${response.status}`); } diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 3a12e811..6f54c5e2 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -10,6 +10,7 @@ import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs"; import { opencodeClient } from "@/lib/opencode/client"; import { respondToPermission } from "@/sync/session-actions"; import { useSessionUIStore } from "@/sync/session-ui-store"; +import { runtimeFetch } from "@/lib/runtime-fetch"; interface PermissionState { autoAccept: PermissionAutoAcceptMap; @@ -237,7 +238,7 @@ export const usePermissionStore = create()( // round-trip. Send known descendants too; server-side // ancestry lookup can lag OpenCode session indexing. for (const scopedSessionId of sessionScope) { - void fetch('/api/notifications/auto-accept', { + void runtimeFetch('/api/notifications/auto-accept', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: scopedSessionId, enabled }), @@ -356,7 +357,7 @@ export const usePermissionStore = create()( // survives page reloads / server restarts. for (const [sid, enabled] of Object.entries(state.autoAccept || {})) { if (enabled === true) { - void fetch('/api/notifications/auto-accept', { + void runtimeFetch('/api/notifications/auto-accept', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sid, enabled: true }), diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index 1c87829b..d5d7658c 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -15,6 +15,7 @@ import { useCommandsStore } from "@/stores/useCommandsStore"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { useSkillsCatalogStore } from "@/stores/useSkillsCatalogStore"; import { useSkillsStore } from "@/stores/useSkillsStore"; +import { runtimeFetch } from "@/lib/runtime-fetch"; // Note: useDirectoryStore cannot be imported at top level to avoid circular dependency // useDirectoryStore -> useAgentsStore (for refreshAfterOpenCodeRestart) @@ -239,7 +240,7 @@ export const useAgentsStore = create()( agents.map(async (agent) => { try { // Force no-cache to ensure we get the latest scope info - const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`, { headers: { 'Cache-Control': 'no-cache', ...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}), @@ -328,7 +329,7 @@ export const useAgentsStore = create()( const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; - const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -389,7 +390,7 @@ export const useAgentsStore = create()( const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; - const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -439,7 +440,7 @@ export const useAgentsStore = create()( const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; - const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { method: 'DELETE', headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined, }); @@ -667,7 +668,7 @@ export async function reloadOpenCodeConfiguration(options?: { try { - const response = await fetch('/api/config/reload', { + const response = await runtimeFetch('/api/config/reload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); diff --git a/packages/ui/src/stores/useCommandsStore.test.ts b/packages/ui/src/stores/useCommandsStore.test.ts new file mode 100644 index 00000000..454510f7 --- /dev/null +++ b/packages/ui/src/stores/useCommandsStore.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +const activeProjectPath = '/workspace/project'; + +let listCommandsWithDetailsCalls = 0; +let listCommandsWithDetailsImpl: () => Promise = async () => []; +let withDirectoryImpl: (_directory: string | null, callback: () => Promise) => Promise = async (_directory, callback) => callback(); +let getDirectoryImpl: () => string = () => '/fallback/project'; +let runtimeFetchImpl: () => Promise = async () => new Response(JSON.stringify({ scope: 'project' }), { + headers: { 'Content-Type': 'application/json' }, +}); + +const listCommandsWithDetailsMock = async () => { + listCommandsWithDetailsCalls += 1; + return listCommandsWithDetailsImpl(); +}; + +const withDirectoryMock = async (directory: string | null, callback: () => Promise) => withDirectoryImpl(directory, callback); +const getDirectoryMock = () => getDirectoryImpl(); +const runtimeFetchMock = async () => runtimeFetchImpl(); + +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + getDirectory: getDirectoryMock, + listCommandsWithDetails: listCommandsWithDetailsMock, + withDirectory: withDirectoryMock, + }, +})); + +mock.module('@/stores/useProjectsStore', () => ({ + useProjectsStore: { + getState: () => ({ + getActiveProject: () => ({ path: activeProjectPath }), + }), + }, +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: runtimeFetchMock, +})); + +mock.module('@/lib/configUpdate', () => ({ + startConfigUpdate: mock(() => undefined), + finishConfigUpdate: mock(() => undefined), + updateConfigUpdateMessage: mock(() => undefined), +})); + +mock.module('@/lib/configSync', () => ({ + emitConfigChange: mock(() => undefined), + scopeMatches: mock(() => false), + subscribeToConfigChanges: mock(() => () => undefined), +})); + +const { useCommandsStore } = await import('./useCommandsStore'); + +describe('useCommandsStore', () => { + beforeEach(() => { + listCommandsWithDetailsCalls = 0; + listCommandsWithDetailsImpl = async () => []; + withDirectoryImpl = async (_directory, callback) => callback(); + getDirectoryImpl = () => '/fallback/project'; + runtimeFetchImpl = async () => new Response(JSON.stringify({ scope: 'project' }), { + headers: { 'Content-Type': 'application/json' }, + }); + + useCommandsStore.setState({ + selectedCommandName: null, + commands: [], + isLoading: false, + commandDraft: null, + }); + }); + + test('loadCommands preserves previous commands when the command list fails', async () => { + const previousCommands = [{ + name: 'existing', + description: 'Existing command', + template: 'do the previous thing', + scope: 'project' as const, + }]; + useCommandsStore.setState({ commands: previousCommands }); + listCommandsWithDetailsImpl = async () => { + throw new Error('network down'); + }; + + const result = await useCommandsStore.getState().loadCommands(); + + expect(result).toBe(false); + expect(listCommandsWithDetailsCalls).toBe(3); + expect(useCommandsStore.getState().commands).toEqual(previousCommands); + expect(useCommandsStore.getState().isLoading).toBe(false); + }); +}); diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index 0c08851e..92306b55 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -10,6 +10,7 @@ import { import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { getSafeStorage } from "./utils/safeStorage"; import { useProjectsStore } from "@/stores/useProjectsStore"; +import { runtimeFetch } from "@/lib/runtime-fetch"; export type CommandScope = 'user' | 'project'; @@ -174,7 +175,7 @@ export const useCommandsStore = create()( configurableCommands.map(async (cmd) => { try { // Force no-cache - const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, { headers: { 'Cache-Control': 'no-cache', ...(directory ? { 'x-opencode-directory': directory } : {}), @@ -258,7 +259,7 @@ export const useCommandsStore = create()( const directory = getRequestDirectory(); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; - const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -319,7 +320,7 @@ export const useCommandsStore = create()( const directory = getRequestDirectory(); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; - const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -369,7 +370,7 @@ export const useCommandsStore = create()( const directory = getRequestDirectory(); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; - const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { method: 'DELETE', headers: directory ? { 'x-opencode-directory': directory } : undefined, }); @@ -510,7 +511,7 @@ export async function reloadOpenCodeConfiguration(options?: { message?: string; startConfigUpdate(options?.message || "Reloading OpenCode configuration…"); try { - const response = await fetch('/api/config/reload', { + const response = await runtimeFetch('/api/config/reload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 3758b713..5dc69c10 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -14,6 +14,7 @@ import { updateDesktopSettings } from "@/lib/persistence"; import { useDirectoryStore } from "@/stores/useDirectoryStore"; import { streamDebugEnabled } from "@/stores/utils/streamDebug"; import { parseModelIdentifier } from "@/lib/modelIdentifier"; +import { runtimeFetch } from "@/lib/runtime-fetch"; const MODELS_DEV_API_URL = "https://models.dev/api.json"; const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata"; @@ -108,7 +109,7 @@ const fetchOpenChamberDefaults = async (): Promise => { } // 2. Fetch API (Web/server) - const response = await fetch('/api/config/settings', { + const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' }, }); @@ -416,7 +417,9 @@ const fetchModelsDevMetadata = async (): Promise> => requestInit.credentials = 'same-origin'; } - const response = await fetch(source, requestInit); + const response = isAbsoluteUrl + ? await fetch(source, requestInit) + : await runtimeFetch(source, requestInit); if (!response.ok) { throw new Error(`Metadata request to ${source} returned status ${response.status}`); diff --git a/packages/ui/src/stores/useGitHubAuthStore.ts b/packages/ui/src/stores/useGitHubAuthStore.ts index 1cbc24ad..410267cc 100644 --- a/packages/ui/src/stores/useGitHubAuthStore.ts +++ b/packages/ui/src/stores/useGitHubAuthStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import type { GitHubAuthStatus, RuntimeAPIs } from '@/lib/api/types'; +import { runtimeFetch } from '@/lib/runtime-fetch'; type GitHubAuthStatusWithError = GitHubAuthStatus & { error?: string }; @@ -22,7 +23,7 @@ const fetchStatus = async ( return payload as GitHubAuthStatus; } - const response = await fetch('/api/github/auth/status', { + const response = await runtimeFetch('/api/github/auth/status', { method: 'GET', headers: { Accept: 'application/json' }, }); diff --git a/packages/ui/src/stores/useGitIdentitiesStore.ts b/packages/ui/src/stores/useGitIdentitiesStore.ts index 18c92a82..36ef68a8 100644 --- a/packages/ui/src/stores/useGitIdentitiesStore.ts +++ b/packages/ui/src/stores/useGitIdentitiesStore.ts @@ -12,6 +12,7 @@ import { } from "@/lib/gitApi"; import { updateDesktopSettings } from "@/lib/persistence"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; +import { runtimeFetch } from "@/lib/runtime-fetch"; export type GitIdentityAuthType = 'ssh' | 'token'; @@ -159,7 +160,7 @@ export const useGitIdentitiesStore = create()( if (defaultId === null) { try { - const response = await fetch('/api/config/settings', { + const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' }, }); diff --git a/packages/ui/src/stores/useMcpConfigStore.ts b/packages/ui/src/stores/useMcpConfigStore.ts index c3f381d7..216894e5 100644 --- a/packages/ui/src/stores/useMcpConfigStore.ts +++ b/packages/ui/src/stores/useMcpConfigStore.ts @@ -8,6 +8,7 @@ import { import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; +import { runtimeFetch } from '@/lib/runtime-fetch'; export type McpScope = 'user' | 'project'; @@ -165,7 +166,7 @@ export const useMcpConfigStore = create()( set({ isLoading: true }); try { const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; - const response = await fetch(`/api/config/mcp${queryParams}`, { + const response = await runtimeFetch(`/api/config/mcp${queryParams}`, { headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined, }); if (!response.ok) { @@ -197,7 +198,7 @@ export const useMcpConfigStore = create()( const body = buildMcpBody(config); const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; - const response = await fetch(`/api/config/mcp/${encodeURIComponent(config.name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(config.name)}${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -251,7 +252,7 @@ export const useMcpConfigStore = create()( const body = buildMcpBody(config); const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; - const response = await fetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -304,7 +305,7 @@ export const useMcpConfigStore = create()( try { const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; - const response = await fetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, { method: 'DELETE', headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined, }); diff --git a/packages/ui/src/stores/usePluginsStore.test.ts b/packages/ui/src/stores/usePluginsStore.test.ts index 393369c9..2aab0482 100644 --- a/packages/ui/src/stores/usePluginsStore.test.ts +++ b/packages/ui/src/stores/usePluginsStore.test.ts @@ -113,6 +113,11 @@ const requestBody = (callIndex: number): unknown => { return init?.body ? JSON.parse(String(init.body)) : undefined; }; +const flushPluginFollowUps = async (): Promise => { + await Promise.resolve(); + await Promise.resolve(); +}; + describe('usePluginsStore', () => { beforeEach(() => { resetStore(); @@ -125,6 +130,7 @@ describe('usePluginsStore', () => { queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]); const result = await usePluginsStore.getState().loadPlugins(); + await flushPluginFollowUps(); expect(result).toBe(true); expect(fetchCalls).toHaveLength(2); @@ -139,6 +145,7 @@ describe('usePluginsStore', () => { await usePluginsStore.getState().loadPlugins(); await usePluginsStore.getState().loadPlugins(); + await flushPluginFollowUps(); expect(fetchCalls).toHaveLength(2); }); @@ -279,6 +286,7 @@ describe('usePluginsStore', () => { queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]); const result = await usePluginsStore.getState().loadPlugins(); + await flushPluginFollowUps(); expect(result).toBe(true); expect(fetchCalls[0]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject'); @@ -289,6 +297,7 @@ describe('usePluginsStore', () => { queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]); const result = await usePluginsStore.getState().createEntry({ spec: 'new-plugin@1', scope: 'user' }); + await flushPluginFollowUps(); expect(result.ok).toBe(true); expect(registryCalls()).toHaveLength(1); @@ -301,6 +310,7 @@ describe('usePluginsStore', () => { queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]); const result = await usePluginsStore.getState().updateEntry(entry.id, { spec: 'plugin-b@2' }); + await flushPluginFollowUps(); expect(result.ok).toBe(true); expect(registryCalls()).toHaveLength(1); @@ -313,6 +323,7 @@ describe('usePluginsStore', () => { queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]); const result = await usePluginsStore.getState().updateEntry(entry.id, { options: { enabled: true } }); + await flushPluginFollowUps(); expect(result.ok).toBe(true); expect(String(registryCalls()[0]?.input)).toContain('specs=plugin-a'); diff --git a/packages/ui/src/stores/usePluginsStore.ts b/packages/ui/src/stores/usePluginsStore.ts index 1a3fb5f6..75cafa8f 100644 --- a/packages/ui/src/stores/usePluginsStore.ts +++ b/packages/ui/src/stores/usePluginsStore.ts @@ -8,6 +8,7 @@ import { import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; +import { runtimeFetch } from '@/lib/runtime-fetch'; export type PluginScope = 'user' | 'project'; export type PluginParsedKind = 'npm' | 'path'; @@ -171,7 +172,7 @@ export const usePluginsStore = create()( const request = (async () => { set({ isLoading: true }); try { - const response = await fetch(buildPluginsUrl('/api/config/plugins', configDirectory), { + const response = await runtimeFetch(buildPluginsUrl('/api/config/plugins', configDirectory), { headers: buildDirectoryHeaders(configDirectory), }); if (!response.ok) { @@ -211,7 +212,7 @@ export const usePluginsStore = create()( const configDirectory = getConfigDirectory(); const nextRegistryInfo: Record = { ...get().registryInfo }; for (const chunk of chunkSpecs(specs)) { - const response = await fetch(buildRegistryUrl(chunk, opts?.force === true, configDirectory), { + const response = await runtimeFetch(buildRegistryUrl(chunk, opts?.force === true, configDirectory), { headers: buildDirectoryHeaders(configDirectory), }); if (!response.ok) { @@ -241,7 +242,7 @@ export const usePluginsStore = create()( createEntry: async (input) => { const result = await runPluginMutation('Creating plugin entry…', async (configDirectory) => { - const response = await fetch(buildPluginsUrl('/api/config/plugins/entry', configDirectory), { + const response = await runtimeFetch(buildPluginsUrl('/api/config/plugins/entry', configDirectory), { method: 'POST', headers: buildJsonHeaders(configDirectory), body: JSON.stringify(buildEntryBody(input)), @@ -258,7 +259,7 @@ export const usePluginsStore = create()( const existingSpec = get().entries.find((plugin) => plugin.id === id)?.spec; const nextSpec = input.spec ?? existingSpec; const result = await runPluginMutation('Updating plugin entry…', async (configDirectory) => { - const response = await fetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), { + const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), { method: 'PATCH', headers: buildJsonHeaders(configDirectory), body: JSON.stringify(buildEntryBody(input)), @@ -274,7 +275,7 @@ export const usePluginsStore = create()( deleteEntry: async (id) => { const entryToDelete = get().entries.find((plugin) => plugin.id === id); const result = await runPluginMutation('Deleting plugin entry…', async (configDirectory) => { - const response = await fetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), { + const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), { method: 'DELETE', headers: buildDirectoryHeaders(configDirectory), }); @@ -295,7 +296,7 @@ export const usePluginsStore = create()( readFile: async (id) => { try { const configDirectory = getConfigDirectory(); - const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), { + const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), { headers: buildDirectoryHeaders(configDirectory), }); if (!response.ok) { @@ -310,7 +311,7 @@ export const usePluginsStore = create()( createFile: async (input) => { return runPluginMutation('Creating plugin file…', async (configDirectory) => { - const response = await fetch(buildPluginsUrl('/api/config/plugins/file', configDirectory), { + const response = await runtimeFetch(buildPluginsUrl('/api/config/plugins/file', configDirectory), { method: 'POST', headers: buildJsonHeaders(configDirectory), body: JSON.stringify(input), @@ -321,7 +322,7 @@ export const usePluginsStore = create()( updateFile: async (id, input) => { return runPluginMutation('Updating plugin file…', async (configDirectory) => { - const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), { + const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), { method: 'PUT', headers: buildJsonHeaders(configDirectory), body: JSON.stringify(input), @@ -332,7 +333,7 @@ export const usePluginsStore = create()( deleteFile: async (id) => { const result = await runPluginMutation('Deleting plugin file…', async (configDirectory) => { - const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), { + const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), { method: 'DELETE', headers: buildDirectoryHeaders(configDirectory), }); diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 5e1f8116..97f72f5a 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import type { ProjectEntry } from '@/lib/api/types'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; @@ -10,6 +11,8 @@ import { useDirectoryStore } from './useDirectoryStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { PROJECT_COLORS } from '@/lib/projectMeta'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; /** Pick a color key that's least used among existing projects */ const pickAutoColor = (projects: ProjectEntry[]): string => { @@ -49,6 +52,7 @@ interface ProjectsStore { removeProjectIcon: (id: string) => Promise<{ ok: boolean; error?: string }>; discoverProjectIcon: (id: string, options?: { force?: boolean }) => Promise<{ ok: boolean; skipped?: boolean; reason?: string; error?: string }>; reorderProjects: (fromIndex: number, toIndex: number) => void; + resetForRuntimeSwitch: () => void; validateProjectPath: (path: string) => ProjectPathValidationResult; synchronizeFromSettings: (settings: DesktopSettings) => void; getActiveProject: () => ProjectEntry | null; @@ -58,6 +62,35 @@ const safeStorage = getSafeStorage(); const PROJECTS_STORAGE_KEY = 'projects'; const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId'; +const getLocalRuntimeOrigin = (): string => { + if (typeof window === 'undefined') return ''; + const value = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__; + return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : ''; +}; + +const getProjectsStorageNamespace = (): string => { + const apiBaseUrl = getRuntimeApiBaseUrl().trim().replace(/\/+$/, ''); + if (!apiBaseUrl) return ''; + return apiBaseUrl; +}; + +const getProjectsStorageKey = (): string => { + const namespace = getProjectsStorageNamespace(); + return namespace ? `${PROJECTS_STORAGE_KEY}:${encodeURIComponent(namespace)}` : PROJECTS_STORAGE_KEY; +}; + +const getActiveProjectStorageKey = (): string => { + const namespace = getProjectsStorageNamespace(); + return namespace ? `${ACTIVE_PROJECT_STORAGE_KEY}:${encodeURIComponent(namespace)}` : ACTIVE_PROJECT_STORAGE_KEY; +}; + +const shouldReadLegacyProjectsCache = (): boolean => { + const namespace = getProjectsStorageNamespace(); + if (!namespace) return true; + const localOrigin = getLocalRuntimeOrigin(); + return Boolean(localOrigin && namespace === localOrigin); +}; + const resolveTildePath = (value: string, homeDir?: string | null): string => { const trimmed = value.trim(); if (!trimmed.startsWith('~')) { @@ -240,7 +273,8 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => { const readPersistedProjects = (): ProjectEntry[] => { try { - const raw = safeStorage.getItem(PROJECTS_STORAGE_KEY); + const raw = safeStorage.getItem(getProjectsStorageKey()) + || (shouldReadLegacyProjectsCache() ? safeStorage.getItem(PROJECTS_STORAGE_KEY) : null); if (!raw) { return []; } @@ -252,7 +286,8 @@ const readPersistedProjects = (): ProjectEntry[] => { const readPersistedActiveProjectId = (): string | null => { try { - const raw = safeStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY); + const raw = safeStorage.getItem(getActiveProjectStorageKey()) + || (shouldReadLegacyProjectsCache() ? safeStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY) : null); if (typeof raw === 'string' && raw.trim().length > 0) { return raw.trim(); } @@ -264,16 +299,17 @@ const readPersistedActiveProjectId = (): string | null => { const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => { try { - safeStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects)); + safeStorage.setItem(getProjectsStorageKey(), JSON.stringify(projects)); } catch { // ignored } try { + const activeProjectStorageKey = getActiveProjectStorageKey(); if (activeProjectId) { - safeStorage.setItem(ACTIVE_PROJECT_STORAGE_KEY, activeProjectId); + safeStorage.setItem(activeProjectStorageKey, activeProjectId); } else { - safeStorage.removeItem(ACTIVE_PROJECT_STORAGE_KEY); + safeStorage.removeItem(activeProjectStorageKey); } } catch { // ignored @@ -291,8 +327,7 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI return null; } - const runtimeApis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }) - .__OPENCHAMBER_RUNTIME_APIS__; + const runtimeApis = getRegisteredRuntimeAPIs(); if (!runtimeApis?.runtime?.isVSCode) { return null; } @@ -539,7 +574,7 @@ export const useProjectsStore = create()( const dataUrl = await readFileAsDataUrl(file); const normalizedDataUrl = dataUrl.replace(/^data:[^;]+;/i, `data:${mime};`); - const response = await fetch(`/api/projects/${encodeURIComponent(id)}/icon`, { + const response = await runtimeFetch(`/api/projects/${encodeURIComponent(id)}/icon`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -570,7 +605,7 @@ export const useProjectsStore = create()( } try { - const response = await fetch(`/api/projects/${encodeURIComponent(id)}/icon`, { + const response = await runtimeFetch(`/api/projects/${encodeURIComponent(id)}/icon`, { method: 'DELETE', headers: { Accept: 'application/json', @@ -599,7 +634,7 @@ export const useProjectsStore = create()( } try { - const response = await fetch(`/api/projects/${encodeURIComponent(id)}/icon/discover`, { + const response = await runtimeFetch(`/api/projects/${encodeURIComponent(id)}/icon/discover`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -657,6 +692,18 @@ export const useProjectsStore = create()( persistProjects(nextProjects, activeProjectId); }, + resetForRuntimeSwitch: () => { + if (vscodeWorkspace) { + return; + } + const projects = readPersistedProjects(); + const activeProjectId = readPersistedActiveProjectId(); + const nextActiveProjectId = projects.some((project) => project.id === activeProjectId) + ? activeProjectId + : projects[0]?.id ?? null; + set({ projects, activeProjectId: nextActiveProjectId }); + }, + synchronizeFromSettings: (settings: DesktopSettings) => { if (vscodeWorkspace) { return; diff --git a/packages/ui/src/stores/useQuotaStore.ts b/packages/ui/src/stores/useQuotaStore.ts index 426136ce..bdc8f72c 100644 --- a/packages/ui/src/stores/useQuotaStore.ts +++ b/packages/ui/src/stores/useQuotaStore.ts @@ -7,6 +7,7 @@ import { isVSCodeRuntime } from '@/lib/desktop'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getDefaultModels } from '@/lib/quota/model-families'; import { updateDesktopSettings } from '@/lib/persistence'; +import { runtimeFetch } from '@/lib/runtime-fetch'; const DEFAULT_REFRESH_INTERVAL_MS = 60000; @@ -113,7 +114,7 @@ const loadSettingsFromRuntime = async (): Promise => { } if (!isVSCodeRuntime()) { - const response = await fetch('/api/config/settings', { + const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } }); @@ -182,7 +183,7 @@ export const useQuotaStore = create()( isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true } })); try { - const response = await fetch(`/api/quota/${encodeURIComponent(providerId)}`); + const response = await runtimeFetch(`/api/quota/${encodeURIComponent(providerId)}`); const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error(payload?.error || 'Failed to fetch quota'); diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts index 504e98b9..03e81f1d 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { getSafeStorage } from './utils/safeStorage'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { runtimeFetch } from '@/lib/runtime-fetch'; // --- Types --- @@ -90,7 +91,7 @@ const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds collapsedFolderIds: collapsedSnapshot, updatedAt: Date.now(), }; - void fetch(SESSION_FOLDERS_API_PATH, { + void runtimeFetch(SESSION_FOLDERS_API_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -502,7 +503,7 @@ const hydrateSessionFoldersFromDisk = async (): Promise => { diskHydrationInFlight = true; try { - const response = await fetch(SESSION_FOLDERS_API_PATH).catch(() => null); + const response = await runtimeFetch(SESSION_FOLDERS_API_PATH).catch(() => null); if (!response || !response.ok) { return; } diff --git a/packages/ui/src/stores/useSkillsCatalogStore.ts b/packages/ui/src/stores/useSkillsCatalogStore.ts index 8efceb49..9097f905 100644 --- a/packages/ui/src/stores/useSkillsCatalogStore.ts +++ b/packages/ui/src/stores/useSkillsCatalogStore.ts @@ -16,6 +16,7 @@ import type { import { refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate'; +import { runtimeFetch } from '@/lib/runtime-fetch'; const FALLBACK_SOURCES: SkillsCatalogSource[] = [ { @@ -150,7 +151,7 @@ export const useSkillsCatalogStore = create()( const timeoutId = window.setTimeout(() => controller.abort(), 3000); try { - const response = await fetch(`/api/config/skills/catalog${refresh}`, { + const response = await runtimeFetch(`/api/config/skills/catalog${refresh}`, { method: 'GET', headers: { Accept: 'application/json' }, signal: controller.signal, @@ -227,7 +228,7 @@ export const useSkillsCatalogStore = create()( ? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}` : `?sourceId=${encodeURIComponent(sourceId)}${refresh}`; - const response = await fetch(`/api/config/skills/catalog/source${queryParams}`, { + const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, { method: 'GET', headers: { Accept: 'application/json' }, }); @@ -235,7 +236,7 @@ export const useSkillsCatalogStore = create()( const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null; const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items); if (!response.ok || (!payload?.ok && !hasItems)) { - const fallback = await fetch(`/api/config/skills/catalog${queryParams}`, { + const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, { method: 'GET', headers: { Accept: 'application/json' }, }); @@ -302,7 +303,7 @@ export const useSkillsCatalogStore = create()( } const queryParams = `?${parts.join('&')}`; - const response = await fetch(`/api/config/skills/catalog/source${queryParams}`, { + const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, { method: 'GET', headers: { Accept: 'application/json' }, }); @@ -357,7 +358,7 @@ export const useSkillsCatalogStore = create()( const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch(`/api/config/skills/scan${queryParams}`, { + const response = await runtimeFetch(`/api/config/skills/scan${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(request), @@ -393,7 +394,7 @@ export const useSkillsCatalogStore = create()( const currentDirectory = directoryOverride ?? getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch(`/api/config/skills/install${queryParams}`, { + const response = await runtimeFetch(`/api/config/skills/install${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(request), diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index ed06fa11..a7c8da91 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -8,6 +8,7 @@ import { updateConfigUpdateMessage, } from "@/lib/configUpdate"; import { getSafeStorage } from "./utils/safeStorage"; +import { runtimeFetch } from "@/lib/runtime-fetch"; import { opencodeClient } from '@/lib/opencode/client'; @@ -216,7 +217,7 @@ export const useSkillsStore = create()( try { const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch(`/api/config/skills${queryParams}`); + const response = await runtimeFetch(`/api/config/skills${queryParams}`); if (!response.ok) { throw new Error(`Failed to list skills: ${response.status}`); } @@ -260,7 +261,7 @@ export const useSkillsStore = create()( const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`); + const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`); if (!response.ok) { return null; } @@ -288,7 +289,7 @@ export const useSkillsStore = create()( const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(skillConfig) @@ -338,7 +339,7 @@ export const useSkillsStore = create()( const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(skillConfig) @@ -381,7 +382,7 @@ export const useSkillsStore = create()( const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { method: 'DELETE' }); @@ -430,7 +431,7 @@ export const useSkillsStore = create()( const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `&directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch( + const response = await runtimeFetch( `/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}` ); if (!response.ok) { @@ -449,7 +450,7 @@ export const useSkillsStore = create()( const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch( + const response = await runtimeFetch( `/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`, { method: 'PUT', @@ -469,7 +470,7 @@ export const useSkillsStore = create()( const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await fetch( + const response = await runtimeFetch( `/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`, { method: 'DELETE' } ); diff --git a/packages/ui/src/stores/useSnippetsStore.ts b/packages/ui/src/stores/useSnippetsStore.ts index 84b9944d..ca71cd9f 100644 --- a/packages/ui/src/stores/useSnippetsStore.ts +++ b/packages/ui/src/stores/useSnippetsStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import type { Snippet } from '@/types/snippet'; import { opencodeClient } from '@/lib/opencode/client'; +import { runtimeFetch } from '@/lib/runtime-fetch'; import { useProjectsStore } from '@/stores/useProjectsStore'; export type SnippetScope = 'global' | 'project'; @@ -67,7 +68,7 @@ export const useSnippetsStore = create()( try { const directory = getRequestDirectory(); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; - const response = await fetch(`/api/config/snippets${queryParams}`, { + const response = await runtimeFetch(`/api/config/snippets${queryParams}`, { headers: { 'Cache-Control': 'no-cache', ...(directory ? { 'x-opencode-directory': directory } : {}) }, }); if (!response.ok) throw new Error('Failed to load snippets'); @@ -94,7 +95,7 @@ export const useSnippetsStore = create()( try { const directory = getRequestDirectory(); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; - const response = await fetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}) }, body: JSON.stringify({ content, aliases: options.aliases, description: options.description, scope: options.scope }), @@ -119,7 +120,7 @@ export const useSnippetsStore = create()( try { const directory = getRequestDirectory(); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; - const response = await fetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}) }, body: JSON.stringify(updates), @@ -138,7 +139,7 @@ export const useSnippetsStore = create()( try { const directory = getRequestDirectory(); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; - const response = await fetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, { + const response = await runtimeFetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, { method: 'DELETE', headers: directory ? { 'x-opencode-directory': directory } : undefined, }); @@ -157,7 +158,7 @@ export const useSnippetsStore = create()( if (!/#[a-z0-9_-]+/i.test(text)) return text; const directory = getRequestDirectory(); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; - const response = await fetch(`/api/config/snippets/expand${queryParams}`, { + const response = await runtimeFetch(`/api/config/snippets/expand${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}) }, body: JSON.stringify({ text }), diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 9911914a..7d555937 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -7,6 +7,7 @@ import type { ShortcutCombo } from '@/lib/shortcuts'; import type { DraftStarterRef } from '@/lib/draftStarters'; import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions'; import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; +import { getRuntimeKey } from '@/lib/runtime-switch'; export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context'; export type RightSidebarTab = 'git' | 'files' | 'context'; @@ -106,6 +107,12 @@ const CONTEXT_PANEL_MAX_TABS = 12; const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120; const LEFT_SIDEBAR_MIN_WIDTH = 280; const RIGHT_SIDEBAR_MIN_WIDTH = 360; +const activeMainTabByRuntime = new Map(); + +const runtimeMemoryKey = (value?: string | null): string => { + const key = (value ?? getRuntimeKey()).trim(); + return key || 'default'; +}; const normalizeDirectoryPath = (value: string): string => { if (!value) return ''; @@ -608,6 +615,8 @@ interface UIStore { showSplitAssistantMessageActions: boolean; showMobileSessionStatusBar: boolean; isMobileSessionStatusBarCollapsed: boolean; + mobileSessionPanelOpen: boolean; + mobileSessionFilterProjectId: string | null; isExpandedInput: boolean; reportUsage: boolean; shortcutOverrides: Record; @@ -644,6 +653,8 @@ interface UIStore { setSessionSwitcherOpen: (open: boolean) => void; setSessionDropdownOpen: (open: boolean) => void; setActiveMainTab: (tab: MainTab) => void; + prepareForRuntimeSwitch: (runtimeKey?: string | null) => void; + restoreForRuntimeSwitch: (runtimeKey?: string | null) => void; setMainTabGuard: (guard: MainTabGuard | null) => void; setPendingDiffFile: (filePath: string | null, staged?: boolean) => void; setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void; @@ -742,6 +753,8 @@ interface UIStore { setShowSplitAssistantMessageActions: (value: boolean) => void; setShowMobileSessionStatusBar: (value: boolean) => void; setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; + setMobileSessionPanelOpen: (value: boolean) => void; + setMobileSessionFilterProjectId: (value: string | null) => void; viewPagerPage: 'left' | 'center' | 'right'; setViewPagerPage: (page: 'left' | 'center' | 'right') => void; toggleExpandedInput: () => void; @@ -873,6 +886,8 @@ export const useUIStore = create()( showSplitAssistantMessageActions: false, showMobileSessionStatusBar: false, isMobileSessionStatusBarCollapsed: false, + mobileSessionPanelOpen: false, + mobileSessionFilterProjectId: null, isExpandedInput: false, reportUsage: true, shortcutOverrides: {}, @@ -1348,9 +1363,19 @@ export const useUIStore = create()( if (guard && !guard(tab)) { return; } + activeMainTabByRuntime.set(runtimeMemoryKey(), tab); set({ activeMainTab: tab }); }, + prepareForRuntimeSwitch: (runtimeKey?: string | null) => { + activeMainTabByRuntime.set(runtimeMemoryKey(runtimeKey), get().activeMainTab); + }, + + restoreForRuntimeSwitch: (runtimeKey?: string | null) => { + const restored = activeMainTabByRuntime.get(runtimeMemoryKey(runtimeKey)) ?? 'chat'; + set({ activeMainTab: restored }); + }, + setPendingDiffFile: (filePath, staged = false) => { set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false }); }, @@ -1949,6 +1974,12 @@ export const useUIStore = create()( setIsMobileSessionStatusBarCollapsed: (value) => { set({ isMobileSessionStatusBarCollapsed: value }); }, + setMobileSessionPanelOpen: (value) => { + set({ mobileSessionPanelOpen: value }); + }, + setMobileSessionFilterProjectId: (value) => { + set({ mobileSessionFilterProjectId: value }); + }, setReportUsage: (value) => { set({ reportUsage: value }); }, @@ -2162,6 +2193,7 @@ export const useUIStore = create()( showSplitAssistantMessageActions: state.showSplitAssistantMessageActions, showMobileSessionStatusBar: state.showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed, + mobileSessionFilterProjectId: state.mobileSessionFilterProjectId, shortcutOverrides: state.shortcutOverrides, }) } diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts index a2b825c7..88d0f86d 100644 --- a/packages/ui/src/stores/useUpdateStore.ts +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -12,6 +12,7 @@ import { isVSCodeRuntime, isWebRuntime, } from '@/lib/desktop'; +import { runtimeFetch } from '@/lib/runtime-fetch'; export type UpdateState = { checking: boolean; @@ -106,7 +107,7 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin : undefined; if (currentVersion) params.set('currentVersion', currentVersion); else if (runtime === 'vscode' && vscodeVersion) params.set('currentVersion', vscodeVersion); - const response = await fetch(`/api/openchamber/update-check?${params.toString()}`, { + const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, { method: 'GET', headers: { Accept: 'application/json' }, }); @@ -136,9 +137,7 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null { if (isTauriShell()) { - // Only use Tauri updater when we're on the local instance. - // When viewing a remote host inside the desktop shell, treat update as web update. - return isDesktopLocalOriginActive() ? 'desktop' : 'web'; + return 'desktop'; } if (isVSCodeRuntime()) return 'vscode'; if (isWebRuntime()) return 'web'; @@ -172,7 +171,7 @@ export const useUpdateStore = create()((set, get) => ({ let suggestedSec: number | null = null; if (runtime === 'desktop') { - let desktopInfo = await checkForDesktopUpdates(); + const desktopInfo = await checkForDesktopUpdates(); set({ checking: false, available: desktopInfo?.available ?? false, @@ -181,33 +180,6 @@ export const useUpdateStore = create()((set, get) => ({ nextCheckInSec: null, }); - const sidecarInfo = await checkForWebUpdates('desktop', desktopInfo?.currentVersion); - suggestedSec = sidecarInfo?.nextSuggestedCheckInSec ?? null; - - if (sidecarInfo?.available && !desktopInfo?.available) { - const forcedDesktopInfo = await checkForDesktopUpdates(); - if (forcedDesktopInfo) { - desktopInfo = forcedDesktopInfo; - } - } - - if (sidecarInfo) { - const mergedInfo: UpdateInfo = { - ...(desktopInfo ?? { available: false, currentVersion: sidecarInfo.currentVersion ?? 'unknown' }), - ...sidecarInfo, - currentVersion: desktopInfo?.currentVersion ?? sidecarInfo.currentVersion ?? 'unknown', - available: sidecarInfo.available, - }; - - set({ - available: mergedInfo.available, - info: mergedInfo, - nextCheckInSec: suggestedSec, - }); - } else { - set({ nextCheckInSec: suggestedSec }); - } - return suggestedSec; } else if (runtime === 'web') { info = await checkForWebUpdates('web'); diff --git a/packages/ui/src/sync/__tests__/materialization.test.ts b/packages/ui/src/sync/__tests__/materialization.test.ts index 83cf709d..68d137b4 100644 --- a/packages/ui/src/sync/__tests__/materialization.test.ts +++ b/packages/ui/src/sync/__tests__/materialization.test.ts @@ -15,6 +15,22 @@ function part(id: string, messageID: string, type = "text", text = id): Part { } describe("materializeSessionSnapshots", () => { + test("marks an empty successful page as materialized", () => { + const result = materializeSessionSnapshots( + { message: {}, part: {} }, + "ses_1", + [], + ) + + expect(result.message.ses_1).toEqual([]) + expect(result.messagesChanged).toBe(true) + expect(getSessionMaterializationStatus(result, "ses_1")).toEqual({ + hasMessages: true, + renderable: true, + missingPartMessageIDs: [], + }) + }) + test("materializes messages and parts together", () => { const result = materializeSessionSnapshots( { message: {}, part: {} }, diff --git a/packages/ui/src/sync/__tests__/session-prefetch-cache.test.ts b/packages/ui/src/sync/__tests__/session-prefetch-cache.test.ts index 225d1d4a..58201ca5 100644 --- a/packages/ui/src/sync/__tests__/session-prefetch-cache.test.ts +++ b/packages/ui/src/sync/__tests__/session-prefetch-cache.test.ts @@ -3,6 +3,15 @@ import { describe, expect, test } from "bun:test" import { shouldSkipSessionPrefetch } from "../session-prefetch-cache" describe("shouldSkipSessionPrefetch", () => { + test("does not skip when only metadata exists without cached messages", () => { + expect(shouldSkipSessionPrefetch({ + hasMessages: false, + info: { limit: 200, complete: true, at: 1_000 }, + pageSize: 200, + now: 1_001, + })).toBe(false) + }) + test("does not skip a larger fetch when only a smaller partial prefetch is cached", () => { expect(shouldSkipSessionPrefetch({ hasMessages: true, diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts index be3a4f8a..3a9c130c 100644 --- a/packages/ui/src/sync/bootstrap.ts +++ b/packages/ui/src/sync/bootstrap.ts @@ -1,6 +1,7 @@ import type { OpencodeClient, PermissionRequest, Project, QuestionRequest } from "@opencode-ai/sdk/v2/client" import { retry } from "./retry" import type { GlobalState, State } from "./types" +import { runtimeFetch } from "../lib/runtime-fetch" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) @@ -94,7 +95,7 @@ export async function bootstrapGlobal( if (errors.length === results.length) { let message = errors[0] instanceof Error ? errors[0].message : String(errors[0]) try { - const healthRes = await fetch("/health", { signal: AbortSignal.timeout(4000) }) + const healthRes = await runtimeFetch('/health', { signal: AbortSignal.timeout(4000) }) if (healthRes.ok) { const health = await healthRes.json() if (health.lastOpenCodeError) { diff --git a/packages/ui/src/sync/event-pipeline.ts b/packages/ui/src/sync/event-pipeline.ts index c1a681bb..ebb6736d 100644 --- a/packages/ui/src/sync/event-pipeline.ts +++ b/packages/ui/src/sync/event-pipeline.ts @@ -14,6 +14,7 @@ import type { Event, OpencodeClient, SessionStatus } from "@opencode-ai/sdk/v2/client" import { opencodeClient } from "@/lib/opencode/client" +import { getRuntimeUrlResolver } from "@/lib/runtime-url" import { syncDebug } from "./debug" export type QueuedEvent = { @@ -41,8 +42,6 @@ const RETRY_BACKOFF_BASE_MS = 250 const RETRY_BACKOFF_CAP_VISIBLE_MS = 5_000 const RETRY_BACKOFF_CAP_HIDDEN_OR_OFFLINE_MS = 60_000 const RETRY_BACKOFF_MAX_EXPONENT = 8 -const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\// - export type EventPipelineInput = { sdk: OpencodeClient onEvent: (directory: string, payload: Event) => void @@ -189,30 +188,6 @@ function resolveEventPayload(payload: unknown): Event | null { return null } -function resolveAbsoluteUrl(candidate: string): string { - const normalized = typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : "/api" - if (ABSOLUTE_URL_PATTERN.test(normalized)) { - return normalized - } - - if (typeof window === "undefined") { - return normalized - } - - const baseReference = window.location?.href || window.location?.origin - if (!baseReference) { - return normalized - } - - return new URL(normalized, baseReference).toString() -} - -function toWebSocketUrl(candidate: string): string { - const url = new URL(resolveAbsoluteUrl(candidate)) - url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - return url.toString() -} - function buildGlobalEventWsUrl(lastEventId?: string): string { let baseUrl = "/api" try { @@ -224,11 +199,10 @@ function buildGlobalEventWsUrl(lastEventId?: string): string { baseUrl = "/api" } const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/` - const httpUrl = new URL("global/event/ws", resolveAbsoluteUrl(normalizedBase)) - if (lastEventId && lastEventId.length > 0) { - httpUrl.searchParams.set("lastEventId", lastEventId) - } - return toWebSocketUrl(httpUrl.toString()) + return getRuntimeUrlResolver().websocket( + `${normalizedBase}global/event/ws`, + lastEventId && lastEventId.length > 0 ? { lastEventId } : undefined, + ) } type DirectoryQueue = { diff --git a/packages/ui/src/sync/materialization.ts b/packages/ui/src/sync/materialization.ts index 4d2c5bc7..cb2e436f 100644 --- a/packages/ui/src/sync/materialization.ts +++ b/packages/ui/src/sync/materialization.ts @@ -139,9 +139,10 @@ export function materializeSessionSnapshots( .filter((record) => !!record?.info?.id) .sort((left, right) => cmp(left.info.id, right.info.id)) const nextMessages = snapshots.map((record) => record.info) - const currentMessages = state.message[sessionID] ?? [] + const existingMessages = state.message[sessionID] + const currentMessages = existingMessages ?? [] const messages = mergeMessages(currentMessages, nextMessages) - const messagesChanged = messages !== currentMessages + const messagesChanged = messages !== currentMessages || (existingMessages === undefined && snapshots.length === 0) let partsChanged = false const nextPartState = { ...state.part } diff --git a/packages/ui/src/sync/runtime-live-memory.ts b/packages/ui/src/sync/runtime-live-memory.ts new file mode 100644 index 00000000..0236ff72 --- /dev/null +++ b/packages/ui/src/sync/runtime-live-memory.ts @@ -0,0 +1,50 @@ +import type { SessionStatus } from "@opencode-ai/sdk/v2/client" + +export const LIVE_STATUS_TTL_MS = 15_000 + +type RuntimeLiveStatus = { + runtimeKey: string + directory: string + sessionId: string + status: SessionStatus + expiresAt: number +} + +const liveStatusByRuntime = new Map() + +const keyFor = (runtimeKey: string, directory: string) => `${runtimeKey}\n${directory}` + +export function rememberRuntimeLiveStatus(params: { + runtimeKey: string + directory: string | null | undefined + sessionId: string | null | undefined + status: SessionStatus | null | undefined +}) { + if (!params.runtimeKey || !params.directory || !params.sessionId || !params.status) return + if (params.status.type === "idle") return + + // Evict expired entries on write so keys that are never read again don't + // accumulate (reads are lazy and only prune their own key). + const now = Date.now() + for (const [key, entry] of liveStatusByRuntime) { + if (entry.expiresAt <= now) liveStatusByRuntime.delete(key) + } + + liveStatusByRuntime.set(keyFor(params.runtimeKey, params.directory), { + runtimeKey: params.runtimeKey, + directory: params.directory, + sessionId: params.sessionId, + status: params.status, + expiresAt: Date.now() + LIVE_STATUS_TTL_MS, + }) +} + +export function getRuntimeLiveStatusSeed(runtimeKey: string, directory: string): RuntimeLiveStatus | null { + const entry = liveStatusByRuntime.get(keyFor(runtimeKey, directory)) + if (!entry) return null + if (entry.expiresAt <= Date.now()) { + liveStatusByRuntime.delete(keyFor(runtimeKey, directory)) + return null + } + return entry +} diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 2c66ac60..709980e1 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -3,6 +3,7 @@ import type { PermissionRequest } from "@/types/permission" // Mock SDK client that records permission.reply / question.reply calls const replyCalls: Array<{ method: string; params: Record }> = [] +let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} const mockScopedClient = { permission: { @@ -24,6 +25,20 @@ const mockScopedClient = { } const mockSdk = { + session: { + messages: mock((params: Record) => { + replyCalls.push({ method: "session.messages", params }) + return Promise.resolve({ data: [] }) + }), + revert: mock((params: Record) => { + replyCalls.push({ method: "session.revert", params }) + return Promise.resolve(sessionRevertResult) + }), + abort: mock((params: Record) => { + replyCalls.push({ method: "session.abort", params }) + return Promise.resolve({ data: true }) + }), + }, permission: { reply: mock((params: Record) => { replyCalls.push({ method: "permission.reply", params }) @@ -48,6 +63,25 @@ mock.module("@/lib/opencode/client", () => ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars getScopedSdkClient: (_: string) => mockScopedClient, getDirectory: () => "/test/project", + replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => { + replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } }) + return Promise.resolve(true) + }), + replyToQuestion: mock((requestId: string, answers: string[] | string[][], directory?: string | null) => { + replyCalls.push({ method: "question.reply", params: { requestID: requestId, answers, directory } }) + return Promise.resolve(true) + }), + revertSession: mock((sessionId: string, messageId: string, partId?: string, directory?: string | null) => { + replyCalls.push({ + method: "session.revert", + params: { sessionID: sessionId, messageID: messageId, partID: partId, directory }, + }) + if (sessionRevertResult.error) { + const status = sessionRevertResult.response?.status + throw new Error(`session.revert failed${status ? ` (${status})` : ""}: rejected`) + } + return Promise.resolve(sessionRevertResult.data) + }), }, })) @@ -74,9 +108,24 @@ mock.module("./session-ui-store", () => ({ }, })) -// Mock useInputStore (imported but not used in permission functions) +// Mock useInputStore +const inputState = { + pendingInputText: "", + pendingInputMode: "normal" as const, + attachedFiles: [], + clearAttachedFiles: () => { + inputState.attachedFiles = [] + }, + addRestoredAttachment: (attachment: never) => { + inputState.attachedFiles = [...inputState.attachedFiles, attachment] + }, +} + mock.module("./input-store", () => ({ - useInputStore: {}, + useInputStore: { + getState: () => inputState, + setState: (patch: Partial) => Object.assign(inputState, patch), + }, })) // Mock useGlobalSessionsStore (imported but not used in permission functions) @@ -92,11 +141,15 @@ mock.module("./sync-refs", () => ({ import { create, type StoreApi } from "zustand" import { INITIAL_STATE } from "./types" import type { DirectoryStore } from "./child-store" -import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" -function createStore(permissions: Record): StoreApi { +function createStore( + permissions: Record, + state?: Partial, +): StoreApi { return create()((set) => ({ ...INITIAL_STATE, + ...state, permission: permissions, patch: (partial) => set(partial), replace: (next) => set(next), @@ -117,6 +170,7 @@ function createChildStores(entries: Array<[string, StoreApi]>) { describe("respondToPermission passes directory", () => { beforeEach(() => { replyCalls.length = 0 + sessionRevertResult = {} }) test("passes directory from child store when permission is found", async () => { @@ -172,6 +226,73 @@ describe("respondToPermission passes directory", () => { }) }) +describe("revertToMessage passes session directory", () => { + beforeEach(() => { + replyCalls.length = 0 + sessionRevertResult = {} + Object.assign(inputState, { + pendingInputText: "previous draft", + pendingInputMode: "normal" as const, + attachedFiles: [], + }) + }) + + test("routes revert through the session directory instead of the current directory", async () => { + const session = { id: "session-a", time: { created: 1 } } as Session + const targetMessage = { id: "msg_2", sessionID: "session-a", role: "user", time: { created: 2 } } as Message + const targetPart = { id: "prt_2", messageID: "msg_2", type: "text", text: "edit this" } as Part + const sessionStore = createStore({}, { + session: [session], + message: { "session-a": [targetMessage] }, + part: { "msg_2": [targetPart] }, + }) + const currentStore = createStore({}) + const childStores = createChildStores([ + ["/test/project", sessionStore], + ["/current/project", currentStore], + ]) + sessionRevertResult = { data: { id: "session-a", time: { created: 1, updated: 2 }, revert: { messageID: "msg_2" } } } + + const { setActionRefs, revertToMessage } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project") + + await revertToMessage("session-a", "msg_2") + + expect(replyCalls.find((call) => call.method === "session.revert")?.params.directory).toBe("/test/project") + expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert?.messageID).toBe("msg_2") + expect(currentStore.getState().session).toHaveLength(0) + expect(inputState.pendingInputText).toBe("edit this") + }) + + test("rolls back optimistic revert when the SDK returns an error", async () => { + const session = { id: "session-a", time: { created: 1 } } as Session + const targetMessage = { id: "msg_2", sessionID: "session-a", role: "user", time: { created: 2 } } as Message + const targetPart = { id: "prt_2", messageID: "msg_2", type: "text", text: "edit this" } as Part + const sessionStore = createStore({}, { + session: [session], + message: { "session-a": [targetMessage] }, + part: { "msg_2": [targetPart] }, + }) + const childStores = createChildStores([["/test/project", sessionStore]]) + sessionRevertResult = { error: { message: "rejected" }, response: { status: 500 } } + + const { setActionRefs, revertToMessage } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project") + + let thrown: unknown + try { + await revertToMessage("session-a", "msg_2") + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toContain("session.revert failed (500)") + expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert).toBe(undefined) + expect(inputState.pendingInputText).toBe("previous draft") + }) +}) + describe("dismissPermission passes directory", () => { beforeEach(() => { replyCalls.length = 0 diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 8436c644..59e5042b 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -31,6 +31,46 @@ let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => voi const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +type SdkResult = { + data?: T + error?: unknown + response?: { status?: number } +} + +function formatSdkError(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === "string") return error + if (error && typeof error === "object") { + const message = (error as { message?: unknown }).message + if (typeof message === "string" && message.length > 0) return message + + const data = (error as { data?: unknown }).data + if (data && typeof data === "object") { + const dataMessage = (data as { message?: unknown }).message + if (typeof dataMessage === "string" && dataMessage.length > 0) return dataMessage + } + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function assertSdkSuccess(result: SdkResult, operation: string): T | undefined { + if (!result.error) return result.data + const status = result.response?.status + throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) +} + +function assertSdkData(result: SdkResult, operation: string): T { + const data = assertSdkSuccess(result, operation) + if (data === undefined || data === null) { + throw new Error(`${operation} failed: empty response`) + } + return data +} + export function setActionRefs( sdk: OpencodeClient, childStores: ChildStoreManager, @@ -61,6 +101,20 @@ function dirStore() { return _childStores.ensureChild(d) } +function dirStoreForDirectory(directory: string) { + if (!_childStores) throw new Error("Child stores not initialized") + if (!directory) throw new Error("No directory") + return _childStores.ensureChild(directory) +} + +function dirStoreForSession(sessionId: string): { store: DirectoryStoreApi; directory?: string } { + const directory = getSessionDirectory(sessionId) + if (directory) { + return { store: dirStoreForDirectory(directory), directory } + } + return { store: dirStore(), directory: dir() } +} + function dir() { return _getDirectory() || undefined } @@ -96,15 +150,47 @@ export async function waitForConnectionOrThrow(): Promise { throw connectionLostError() } -function getSessionDirectory(sessionId: string): string | undefined { - return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir() +type SessionListSnapshot = { + directory: string + sessions: Session[] } -function getDirectoryStore(directory?: string) { - if (!_childStores) throw new Error("Child stores not initialized") - const resolvedDirectory = directory || _getDirectory() - if (!resolvedDirectory) throw new Error("No current directory") - return _childStores.ensureChild(resolvedDirectory) +type DirectoryStoreApi = ReturnType + +function getGlobalSessionSnapshot(sessionId: string): Session | null { + const global = useGlobalSessionsStore.getState() + return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null +} + +function restoreGlobalSessionSnapshot(session: Session | null): void { + if (!session) return + useGlobalSessionsStore.getState().upsertSession(session) +} + +function getSessionDirectory(sessionId: string): string | undefined { + return findSessionDirectoryInChildStores(sessionId) + || useSessionUIStore.getState().getDirectoryForSession(sessionId) + || dir() +} + +function findSessionDirectoryInChildStores(sessionId: string): string | null { + const stores = _childStores + if (!stores || !sessionId) return null + + for (const [directory, store] of stores.children) { + const state = store.getState() + if ( + state.session.some((session) => session.id === sessionId) + || Object.prototype.hasOwnProperty.call(state.message, sessionId) + || Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionId) + || Object.prototype.hasOwnProperty.call(state.permission ?? {}, sessionId) + || Object.prototype.hasOwnProperty.call(state.question ?? {}, sessionId) + ) { + return directory + } + } + + return null } function getSessionReplyClient(sessionId?: string): OpencodeClient { @@ -192,13 +278,10 @@ export async function createSession( parentID?: string | null, ): Promise { try { - const result = await sdk().session.create({ - directory: directoryOverride ?? dir(), + const session = await opencodeClient.createSession({ title, parentID: parentID ?? undefined, - }) - const session = result.data - if (!session) return null + }, directoryOverride ?? dir()) const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null // Pre-populate routing index so SSE events arriving before session.created @@ -216,62 +299,70 @@ export async function createSession( } } -/** Optimistically remove a session from the child store list. Returns previous list for rollback. */ -function optimisticRemoveSession(sessionId: string, directory?: string): Session[] | null { - const store = getDirectoryStore(directory) - const current = store.getState() - const sessions = [...current.session] - const result = Binary.search(sessions, sessionId, (s) => s.id) - if (result.found) { - const snapshot = current.session - sessions.splice(result.index, 1) - store.setState({ session: sessions }) - return snapshot +/** Optimistically remove a session from every live child store that has it. */ +function optimisticRemoveSession(sessionId: string, preferredDirectory?: string): SessionListSnapshot[] { + if (!_childStores) return [] + + const snapshots: SessionListSnapshot[] = [] + const visited = new Set() + const candidates: Array<[string, DirectoryStoreApi]> = [] + + if (preferredDirectory) { + const preferredStore = _childStores.children.get(preferredDirectory) + if (preferredStore) { + candidates.push([preferredDirectory, preferredStore]) + visited.add(preferredDirectory) + } + } + + for (const entry of _childStores.children.entries()) { + if (visited.has(entry[0])) continue + candidates.push(entry) + } + + for (const [directory, store] of candidates) { + const current = store.getState() + if (!current.session.some((session) => session.id === sessionId)) { + continue + } + snapshots.push({ directory, sessions: current.session }) + store.setState({ session: current.session.filter((session) => session.id !== sessionId) }) + } + + return snapshots +} + +function restoreSessionListSnapshots(snapshots: SessionListSnapshot[]): void { + if (!_childStores) return + for (const snapshot of snapshots) { + const store = _childStores.children.get(snapshot.directory) + if (!store) continue + store.setState({ session: snapshot.sessions }) } - return null } // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function deleteSession(sessionId: string, _options?: Record): Promise { const sessionDirectory = getSessionDirectory(sessionId) - // Remove from UI immediately, rollback on error - let snapshot = optimisticRemoveSession(sessionId, sessionDirectory) - let removedFromDir: string | null = snapshot ? (sessionDirectory ?? null) : null - - // If the session wasn't in the resolved directory (e.g. archived session - // whose original child store was disposed), search all child stores. - if (!snapshot && _childStores) { - for (const [dir, store] of _childStores.children.entries()) { - const current = store.getState() - const sessions = [...current.session] - const result = Binary.search(sessions, sessionId, (s) => s.id) - if (result.found) { - snapshot = current.session - sessions.splice(result.index, 1) - store.setState({ session: sessions }) - removedFromDir = dir - break - } - } - } + const snapshots = optimisticRemoveSession(sessionId, sessionDirectory) + const globalSnapshot = getGlobalSessionSnapshot(sessionId) + useGlobalSessionsStore.getState().removeSessions([sessionId]) const ui = useSessionUIStore.getState() if (ui.currentSessionId === sessionId) { ui.setCurrentSession(null) } try { - await sdk().session.delete({ sessionID: sessionId, directory: sessionDirectory }) + const deleted = await opencodeClient.deleteSession(sessionId, sessionDirectory) + if (deleted !== true) { + throw new Error("session.delete failed: server did not confirm deletion") + } useGlobalSessionsStore.getState().removeSessions([sessionId]) return true } catch (error) { console.error("[session-actions] deleteSession failed", error) - if (snapshot && removedFromDir) { - try { - getDirectoryStore(removedFromDir).setState({ session: snapshot }) - } catch { - // child store may have been disposed since — ignore rollback - } - } + restoreSessionListSnapshots(snapshots) + restoreGlobalSessionSnapshot(globalSnapshot) return false } } @@ -279,72 +370,71 @@ export async function deleteSession(sessionId: string, _options?: Record { if (!_childStores) return false - const store = _childStores.ensureChild(directory) - const current = store.getState() - const sessions = [...current.session] - const result = Binary.search(sessions, sessionId, (s) => s.id) - let snapshot: Session[] | null = null - if (result.found) { - snapshot = current.session - sessions.splice(result.index, 1) - store.setState({ session: sessions }) - } + const snapshots = optimisticRemoveSession(sessionId, directory) + const globalSnapshot = getGlobalSessionSnapshot(sessionId) + useGlobalSessionsStore.getState().removeSessions([sessionId]) const ui = useSessionUIStore.getState() if (ui.currentSessionId === sessionId) ui.setCurrentSession(null) try { - await sdk().session.delete({ sessionID: sessionId, directory }) + const deleted = await opencodeClient.deleteSession(sessionId, directory) + if (deleted !== true) { + throw new Error("session.delete failed: server did not confirm deletion") + } useGlobalSessionsStore.getState().removeSessions([sessionId]) return true } catch (error) { console.error("[session-actions] deleteSessionInDirectory failed", error) - if (snapshot) store.setState({ session: snapshot }) + restoreSessionListSnapshots(snapshots) + restoreGlobalSessionSnapshot(globalSnapshot) return false } } export async function archiveSession(sessionId: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) - const snapshot = optimisticRemoveSession(sessionId, sessionDirectory) + const snapshots = optimisticRemoveSession(sessionId, sessionDirectory) + const globalSnapshot = getGlobalSessionSnapshot(sessionId) + const archivedAt = Date.now() + useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt) const ui = useSessionUIStore.getState() if (ui.currentSessionId === sessionId) { ui.setCurrentSession(null) } try { - const archivedAt = Date.now() - await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, time: { archived: archivedAt } }) - useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt) + const archived = await opencodeClient.updateSession(sessionId, { time: { archived: archivedAt } }, sessionDirectory) + if (!archived) { + throw new Error("session.update failed: server did not return the archived session") + } + useGlobalSessionsStore.getState().upsertSession(archived) return true } catch (error) { console.error("[session-actions] archiveSession failed", error) - if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot }) + restoreSessionListSnapshots(snapshots) + restoreGlobalSessionSnapshot(globalSnapshot) return false } } export async function updateSessionTitle(sessionId: string, title: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) - const result = await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, title }) - if (result.data) { - useGlobalSessionsStore.getState().upsertSession(result.data) - } + const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory) + useGlobalSessionsStore.getState().upsertSession(session) } export async function shareSession(sessionId: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory }) - if (result.data) { - useGlobalSessionsStore.getState().upsertSession(result.data) - } - return result.data ?? null + const session = assertSdkData(result, "session.share") + useGlobalSessionsStore.getState().upsertSession(session) + return session } export async function unshareSession(sessionId: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory }) - if (result.data) { - useGlobalSessionsStore.getState().upsertSession(result.data) - } - return result.data ?? null + const session = assertSdkData(result, "session.unshare") + useGlobalSessionsStore.getState().upsertSession(session) + return session } // --------------------------------------------------------------------------- @@ -494,12 +584,7 @@ export async function respondToPermission( const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId) || getSessionDirectory(sessionId) || dir() - const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ - requestID: requestId, - reply: response, - ...(directory ? { directory } : {}), - }) - if (!result.data) { + if (await opencodeClient.replyToPermission(requestId, response, { directory }) !== true) { throw new Error("Permission reply failed") } } @@ -512,12 +597,7 @@ export async function dismissPermission( const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId) || getSessionDirectory(sessionId) || dir() - const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ - requestID: requestId, - reply: "reject", - ...(directory ? { directory } : {}), - }) - if (!result.data) { + if (await opencodeClient.replyToPermission(requestId, "reject", { directory }) !== true) { throw new Error("Permission dismissal failed") } } @@ -535,12 +615,7 @@ export async function respondToQuestion( const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId) || getSessionDirectory(sessionId) || dir() - const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({ - requestID: requestId, - answers: answers as Array>, - ...(directory ? { directory } : {}), - }) - if (!result.data) { + if (await opencodeClient.replyToQuestion(requestId, answers, directory) !== true) { throw new Error("Question reply failed") } } @@ -557,7 +632,7 @@ export async function rejectQuestion( requestID: requestId, ...(directory ? { directory } : {}), }) - if (!result.data) { + if (assertSdkData(result, "question.reject") !== true) { throw new Error("Question rejection failed") } } @@ -572,18 +647,18 @@ export async function rejectQuestion( * 1. Abort if session is busy * 2. Extract text from the target message for prompt restoration * 3. Optimistically set revert marker so messages hide immediately - * 4. Call SDK session.revert() and merge returned session + * 4. Call the runtime revert endpoint and merge returned session * 5. Set pendingInputText so the reverted message text appears in the input */ export async function revertToMessage(sessionId: string, messageId: string): Promise { - const store = dirStore() + const { store, directory } = dirStoreForSession(sessionId) const state = store.getState() // Abort if busy before mutating session state const status = state.session_status[sessionId] if (status && status.type !== "idle") { try { - await sdk().session.abort({ sessionID: sessionId, directory: dir() }) + await sdk().session.abort({ sessionID: sessionId, directory }) } catch { // ignore abort errors } @@ -649,16 +724,13 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro // Call SDK and merge authoritative result into store try { - const directory = dir() - const result = await sdk().session.revert({ sessionID: sessionId, directory, messageID: messageId }) - if (result.data) { - const current = store.getState() - const updated = [...current.session] - const idx = updated.findIndex((s) => s.id === sessionId) - if (idx >= 0) { - updated[idx] = result.data - store.setState({ session: updated }) - } + const revertedSession = await opencodeClient.revertSession(sessionId, messageId, undefined, directory) + const current = store.getState() + const updated = [...current.session] + const idx = updated.findIndex((s) => s.id === sessionId) + if (idx >= 0) { + updated[idx] = revertedSession + store.setState({ session: updated }) } if (directory) { sessionEvents.requestGitRefresh({ directory }) @@ -685,9 +757,10 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro } export async function refetchSessionMessages(sessionId: string): Promise { - const store = dirStore() - const result = await sdk().session.messages({ sessionID: sessionId, directory: dir(), limit: MESSAGE_REFETCH_LIMIT }) - const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id) + const { store, directory } = dirStoreForSession(sessionId) + const result = await sdk().session.messages({ sessionID: sessionId, directory, limit: MESSAGE_REFETCH_LIMIT }) + const records = (assertSdkSuccess(result, "session.messages") ?? []) + .filter((record: { info?: { id?: string } }) => !!record?.info?.id) if (records.length === 0) return store.setState((state) => { @@ -709,7 +782,7 @@ export async function refetchSessionMessages(sessionId: string): Promise { * Restore all previously reverted messages. Aborts if busy, merges result. */ export async function unrevertSession(sessionId: string): Promise { - const store = dirStore() + const { store, directory } = dirStoreForSession(sessionId) const state = store.getState() const previousMessageCount = state.message[sessionId]?.length ?? 0 @@ -717,21 +790,20 @@ export async function unrevertSession(sessionId: string): Promise { const status = state.session_status[sessionId] if (status && status.type !== "idle") { try { - await sdk().session.abort({ sessionID: sessionId, directory: dir() }) + await sdk().session.abort({ sessionID: sessionId, directory }) } catch { // ignore } } - const result = await sdk().session.unrevert({ sessionID: sessionId, directory: dir() }) - if (result.data) { - const current = store.getState() - const sessions = [...current.session] - const idx = sessions.findIndex((s) => s.id === sessionId) - if (idx >= 0) { - sessions[idx] = result.data - store.setState({ session: sessions }) - } + const result = await sdk().session.unrevert({ sessionID: sessionId, directory }) + const unrevertedSession = assertSdkData(result, "session.unrevert") + const current = store.getState() + const sessions = [...current.session] + const idx = sessions.findIndex((s) => s.id === sessionId) + if (idx >= 0) { + sessions[idx] = unrevertedSession + store.setState({ session: sessions }) } for (let attempt = 0; attempt < UNREVERT_REFETCH_ATTEMPTS; attempt += 1) { if (attempt > 0) await wait(UNREVERT_REFETCH_RETRY_MS) @@ -745,12 +817,12 @@ export async function unrevertSession(sessionId: string): Promise { * Fork from a user message. * * 1. Extract text from the message for input restoration - * 2. Call SDK session.fork() + * 2. Call the runtime fork endpoint * 3. Insert the new session into the child store (so sidebar updates immediately) * 4. Switch to new session and set pending input text */ export async function forkFromMessage(sessionId: string, messageId: string): Promise { - const store = dirStore() + const { store, directory } = dirStoreForSession(sessionId) const state = store.getState() // Extract message text and file attachments for input restoration. @@ -766,10 +838,7 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro .trim() const fileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array> - const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId }) - if (!result.data) return - - const forkedSession = result.data + const forkedSession = await opencodeClient.forkSession(sessionId, messageId, directory) // Insert new session into child store so sidebar updates immediately const current = store.getState() diff --git a/packages/ui/src/sync/session-navigation.ts b/packages/ui/src/sync/session-navigation.ts new file mode 100644 index 00000000..8303b03e --- /dev/null +++ b/packages/ui/src/sync/session-navigation.ts @@ -0,0 +1,11 @@ +type SessionOpener = (sessionID: string, directory: string) => void + +let sessionOpener: SessionOpener | null = null + +export const setSessionOpener = (opener: SessionOpener | null) => { + sessionOpener = opener +} + +export const openSessionFromToast = (sessionID: string, directory: string) => { + sessionOpener?.(sessionID, directory) +} diff --git a/packages/ui/src/sync/session-prefetch-cache.ts b/packages/ui/src/sync/session-prefetch-cache.ts index 24335afe..920be7ea 100644 --- a/packages/ui/src/sync/session-prefetch-cache.ts +++ b/packages/ui/src/sync/session-prefetch-cache.ts @@ -38,15 +38,16 @@ export function shouldSkipSessionPrefetch(input: { pageSize: number now?: number }): boolean { - if (input.hasMessages) { - if (!input.info) return true - if (input.info.complete) return true - if (input.info.limit > input.pageSize) return true - if (input.info.limit < input.pageSize) return false - } else { - if (!input.info) return false + if (!input.hasMessages) { + return false } - return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL + + const info = input.info + if (!info) return true + if (info.complete) return true + if (info.limit > input.pageSize) return true + if (info.limit < input.pageSize) return false + return (input.now ?? Date.now()) - info.at < SESSION_PREFETCH_TTL } export function getSessionPrefetch(directory: string, sessionID: string): Meta | undefined { diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 0d74077a..af637401 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from 'bun:test'; +import { opencodeClient } from '@/lib/opencode/client'; import { useSessionWorktreeStore } from './session-worktree-store'; -import { useSessionUIStore } from './session-ui-store'; +import { routeMessage, useSessionUIStore } from './session-ui-store'; /** * Unit tests for session worktree routing through the authoritative store. @@ -189,3 +190,47 @@ describe('session-worktree-store worktree routing', () => { expect(attachment.worktreeStatus).toBe('not-a-repo'); }); }); + +describe('routeMessage directory scoping', () => { + test('runs sends in the provided session directory', async () => { + const calls = []; + let activeDirectory = '/current/project'; + const originalWithDirectory = opencodeClient.withDirectory; + const originalGetDirectory = opencodeClient.getDirectory; + const originalShellSession = opencodeClient.shellSession; + + opencodeClient.withDirectory = async (directory, fn) => { + calls.push({ method: 'withDirectory', directory }); + const previousDirectory = activeDirectory; + activeDirectory = directory ?? undefined; + try { + return await fn(); + } finally { + activeDirectory = previousDirectory; + } + }; + opencodeClient.getDirectory = () => activeDirectory; + opencodeClient.shellSession = async (params) => { + calls.push({ method: 'session.shell', params }); + return { info: {}, parts: [] }; + }; + + try { + await routeMessage({ + sessionId: 'session-a', + directory: '/session/project', + content: 'pwd', + providerID: 'provider-a', + modelID: 'model-a', + inputMode: 'shell', + }); + } finally { + opencodeClient.withDirectory = originalWithDirectory; + opencodeClient.getDirectory = originalGetDirectory; + opencodeClient.shellSession = originalShellSession; + } + + expect(calls[0]).toEqual({ method: 'withDirectory', directory: '/session/project' }); + expect(calls[1].params.directory).toBe('/session/project'); + }); +}); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 61a4183b..0e52e389 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -17,6 +17,7 @@ import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/clien import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes" import type { WorktreeMetadata } from "@/types/worktree" import { opencodeClient } from "@/lib/opencode/client" +import { runtimeFetch } from "@/lib/runtime-fetch" import { useConfigStore } from "@/stores/useConfigStore" import { useProjectsStore } from "@/stores/useProjectsStore" import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from "@/stores/useGlobalSessionsStore" @@ -48,12 +49,18 @@ import { unshareSession as unshareSessionAction, optimisticSend, refetchSessionMessages, + revertToMessage as revertToMessageAction, + unrevertSession as unrevertSessionAction, + forkFromMessage as forkFromMessageAction, } from "./session-actions" import { useInputStore, type SyntheticContextPart } from "./input-store" import { useSelectionStore } from "./selection-store" -import { useViewportStore } from "./viewport-store" +import { getViewportSessionMemory, useViewportStore, viewportSessionKey } from "./viewport-store" import { useSessionWorktreeStore } from "./session-worktree-store" import { getAttachedSessionDirectory } from "./session-worktree-contract" +import { setSessionOpener } from "./session-navigation" +import { getRuntimeKey } from "@/lib/runtime-switch" +import { rememberRuntimeLiveStatus } from "./runtime-live-memory" export type { AttachedFile } @@ -74,82 +81,75 @@ export function routeMessage(params: { files?: Array<{ type: "file"; mime: string; url: string; filename: string }> additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }> }): Promise { - const run = (): Promise => { - if (params.inputMode === "shell") { - const sdk = opencodeClient.getSdkClient() - const dir = opencodeClient.getDirectory() || undefined - return sdk.session.shell({ - sessionID: params.sessionId, - directory: dir, - agent: params.agent, - model: { providerID: params.providerID, modelID: params.modelID }, - command: params.content, - }).then(() => {}) - } - - // Slash commands — fire and forget, SSE delivers messages and status - if (params.content.startsWith("/")) { - const [head, ...tail] = params.content.split(" ") - const cmdName = head.slice(1) - - const dirState = getDirectoryState(params.directory ?? undefined) - const syncCommands = dirState?.command ?? [] - const storeCommands = useCommandsStore.getState().commands - - const isCommand = syncCommands.find((c) => c.name === cmdName) - || storeCommands.find((c) => c.name === cmdName) - - if (isCommand) { - return optimisticSend({ - sessionId: params.sessionId, - content: params.content, - providerID: params.providerID, - modelID: params.modelID, - agent: params.agent, - files: params.files, - send: (messageID) => opencodeClient.sendCommand({ - id: params.sessionId, - providerID: params.providerID, - modelID: params.modelID, - command: cmdName, - arguments: tail.join(" "), - agent: params.agent, - variant: params.variant, - files: params.files, - messageId: messageID, - }).then(() => {}), - }) - } - } - - // Normal prompt — optimistic insert so message appears instantly - return optimisticSend({ + const requestDirectory = params.directory ?? undefined + if (params.inputMode === "shell") { + return opencodeClient.shellSession({ sessionId: params.sessionId, - content: params.content, - providerID: params.providerID, - modelID: params.modelID, - agent: params.agent, - files: params.files, - send: (messageID) => opencodeClient.sendMessage({ - id: params.sessionId, + directory: requestDirectory, + agent: params.agent ?? "", + model: { providerID: params.providerID, modelID: params.modelID }, + command: params.content, + }).then(() => undefined) + } + + // Slash commands — fire and forget, SSE delivers messages and status + if (params.content.startsWith("/")) { + const [head, ...tail] = params.content.split(" ") + const cmdName = head.slice(1) + + const dirState = getDirectoryState(requestDirectory) + const syncCommands = dirState?.command ?? [] + const storeCommands = useCommandsStore.getState().commands + + const isCommand = syncCommands.find((c) => c.name === cmdName) + || storeCommands.find((c) => c.name === cmdName) + + if (isCommand) { + return optimisticSend({ + sessionId: params.sessionId, + content: params.content, providerID: params.providerID, modelID: params.modelID, - text: params.content, agent: params.agent, - agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined, - variant: params.variant, files: params.files, - additionalParts: params.additionalParts, - messageId: messageID, - }).then(() => {}), - }) + send: (messageID) => opencodeClient.sendCommand({ + id: params.sessionId, + providerID: params.providerID, + modelID: params.modelID, + command: cmdName, + arguments: tail.join(" "), + agent: params.agent, + variant: params.variant, + files: params.files, + messageId: messageID, + directory: requestDirectory, + }).then(() => {}), + }) + } } - if (params.directory !== undefined) { - return opencodeClient.withDirectory(params.directory, run) - } - - return run() + // Normal prompt — optimistic insert so message appears instantly + return optimisticSend({ + sessionId: params.sessionId, + content: params.content, + providerID: params.providerID, + modelID: params.modelID, + agent: params.agent, + files: params.files, + send: (messageID) => opencodeClient.sendMessage({ + id: params.sessionId, + providerID: params.providerID, + modelID: params.modelID, + text: params.content, + agent: params.agent, + agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined, + variant: params.variant, + files: params.files, + additionalParts: params.additionalParts, + messageId: messageID, + directory: requestDirectory, + }).then(() => {}), + }) } type SendMessageOptions = { @@ -157,7 +157,7 @@ type SendMessageOptions = { } function notifyMessageSent(sessionId: string): void { - fetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" }) + runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" }) .catch(() => { /* ignore */ }) } @@ -222,6 +222,8 @@ export type SessionUIState = { // Actions — UI state management setCurrentSession: (id: string | null, directoryHint?: string | null) => void + prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void + restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void openNewSessionDraft: (options?: Partial) => void closeNewSessionDraft: () => void setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void @@ -358,6 +360,31 @@ const DEFAULT_DRAFT: NewSessionDraftState = { parentID: null, } +const activeSessionByRuntime = new Map() +type RuntimeSessionMemory = { + sessionId: string | null + directory: string | null + draft: NewSessionDraftState +} +const runtimeSessionMemory = new Map() + +const runtimeMemoryKey = (value?: string | null): string => { + const key = (value ?? getRuntimeKey()).trim() + return key || "default" +} + +const cloneDraft = (draft: NewSessionDraftState): NewSessionDraftState => ({ ...draft }) + +const writeRuntimeSessionMemory = (key: string, patch: Partial): void => { + const current = runtimeSessionMemory.get(key) + runtimeSessionMemory.set(key, { + sessionId: current?.sessionId ?? null, + directory: current?.directory ?? null, + draft: current?.draft ? cloneDraft(current.draft) : { ...DEFAULT_DRAFT }, + ...patch, + }) +} + // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- @@ -387,6 +414,9 @@ export const useSessionUIStore = create()((set, get) => ({ get().closeNewSessionDraft() } + const key = runtimeMemoryKey() + activeSessionByRuntime.set(key, id) + const previousSessionId = get().currentSessionId // Set currentSessionId immediately so the skeleton renders without delay. @@ -400,6 +430,7 @@ export const useSessionUIStore = create()((set, get) => ({ ) const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir + writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null }) try { if (resolvedDir && directoryState.currentDirectory !== resolvedDir) { @@ -415,7 +446,7 @@ export const useSessionUIStore = create()((set, get) => ({ if (previousSessionId && previousSessionId !== id) { const prevId = previousSessionId setTimeout(() => { - const memState = useViewportStore.getState().sessionMemoryState.get(prevId) + const memState = getViewportSessionMemory(prevId) if (!memState?.isStreaming) { const prevMessages = getSyncMessages(prevId) if (prevMessages.length > 0) { @@ -432,6 +463,50 @@ export const useSessionUIStore = create()((set, get) => ({ } }, + prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => { + const key = runtimeMemoryKey(apiBaseUrl) + const directory = useDirectoryStore.getState().currentDirectory || null + const currentSessionId = get().currentSessionId + const directorySnapshot = directory ? getDirectoryState(directory) : null + rememberRuntimeLiveStatus({ + runtimeKey: key, + directory, + sessionId: currentSessionId, + status: currentSessionId ? directorySnapshot?.session_status?.[currentSessionId] : null, + }) + activeSessionByRuntime.set(key, get().currentSessionId) + writeRuntimeSessionMemory(key, { + sessionId: currentSessionId, + directory, + draft: cloneDraft(get().newSessionDraft), + }) + }, + + restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => { + const key = runtimeMemoryKey(apiBaseUrl) + const memory = runtimeSessionMemory.get(key) + const restoredSessionId = memory?.sessionId ?? activeSessionByRuntime.get(key) ?? null + const restoredDraft = memory?.draft ? cloneDraft(memory.draft) : { ...DEFAULT_DRAFT } + const restoredDirectory = memory?.directory ?? null + if (restoredDirectory) { + useDirectoryStore.getState().setDirectory(restoredDirectory, { showOverlay: false }) + } + set({ + currentSessionId: restoredSessionId, + newSessionDraft: restoredSessionId ? { ...DEFAULT_DRAFT } : restoredDraft, + abortPromptSessionId: null, + abortPromptExpiresAt: null, + error: null, + sessionAbortFlags: new Map(), + pendingChangesBarDismissed: new Map(), + }) + if (restoredSessionId) { + setActiveSession(opencodeClient.getDirectory() ?? "", restoredSessionId) + } else { + setActiveSession("", "") + } + }, + // --------------------------------------------------------------------------- // openNewSessionDraft // --------------------------------------------------------------------------- @@ -481,24 +556,29 @@ export const useSessionUIStore = create()((set, get) => ({ persistDraftTarget({ projectId: selectedProject?.id ?? null, directory }) + const nextDraft: NewSessionDraftState = { + open: true, + selectedProjectId: selectedProject?.id ?? null, + directoryOverride: directory, + pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null, + bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null), + preserveDirectoryOverride: options?.preserveDirectoryOverride === true, + parentID: options?.parentID ?? null, + title: options?.title, + initialPrompt: options?.initialPrompt, + syntheticParts: options?.syntheticParts, + targetFolderId: options?.targetFolderId, + } + set({ newSessionDraft: { - open: true, - selectedProjectId: selectedProject?.id ?? null, - directoryOverride: directory, - pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null, - bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null), - preserveDirectoryOverride: options?.preserveDirectoryOverride === true, - parentID: options?.parentID ?? null, - title: options?.title, - initialPrompt: options?.initialPrompt, - syntheticParts: options?.syntheticParts, - targetFolderId: options?.targetFolderId, + ...nextDraft, }, currentSessionId: null, error: null, }) + writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId: null, directory, draft: nextDraft }) // Clear composer attachments when opening a new session draft. // Attachments from the previous session (e.g. restored by revert) must // not bleed into the new session's input. @@ -515,8 +595,7 @@ export const useSessionUIStore = create()((set, get) => ({ // closeNewSessionDraft // --------------------------------------------------------------------------- closeNewSessionDraft: () => { - set({ - newSessionDraft: { + const nextDraft: NewSessionDraftState = { open: false, selectedProjectId: null, directoryOverride: null, @@ -528,8 +607,11 @@ export const useSessionUIStore = create()((set, get) => ({ initialPrompt: undefined, syntheticParts: undefined, targetFolderId: undefined, - }, + } + set({ + newSessionDraft: nextDraft, }) + writeRuntimeSessionMemory(runtimeMemoryKey(), { draft: nextDraft }) }, setNewSessionDraftTarget: (target) => { @@ -841,10 +923,10 @@ export const useSessionUIStore = create()((set, get) => ({ if (targetSessionId) { const viewportState = useViewportStore.getState() - const memState = viewportState.sessionMemoryState.get(targetSessionId) + const memState = getViewportSessionMemory(targetSessionId) if (!memState || !memState.lastUserMessageAt) { const newMemState = new Map(viewportState.sessionMemoryState) - newMemState.set(targetSessionId, { + newMemState.set(viewportSessionKey(targetSessionId), { viewportAnchor: 0, isStreaming: false, lastAccessedAt: Date.now(), @@ -980,8 +1062,7 @@ export const useSessionUIStore = create()((set, get) => ({ // Ensure the complete message range is present before applying the revert // marker. Reverted UI is derived from session.revert + stored messages. await refetchSessionMessages(sessionId) - const { revertToMessage: revert } = await import("./session-actions") - await revert(sessionId, messageId) + await revertToMessageAction(sessionId, messageId) }, // --------------------------------------------------------------------------- @@ -1056,8 +1137,7 @@ export const useSessionUIStore = create()((set, get) => ({ return } - const { unrevertSession } = await import("./session-actions") - await unrevertSession(sessionId) + await unrevertSessionAction(sessionId) const { toast } = await import("sonner") const { useI18nStore, formatMessage } = await import("@/lib/i18n/store") const { dictionary } = useI18nStore.getState() @@ -1073,8 +1153,7 @@ export const useSessionUIStore = create()((set, get) => ({ if (!existingSession) return try { - const { forkFromMessage: fork } = await import("./session-actions") - await fork(sessionId, messageId) + await forkFromMessageAction(sessionId, messageId) const { toast } = await import("sonner") toast.success(`Forked from ${existingSession.title}`) @@ -1127,6 +1206,7 @@ export const useSessionUIStore = create()((set, get) => ({ if (!pID || !mID) return + const sessionDirectory = normalizePath(directory ?? session.directory ?? null) await opencodeClient.sendMessage({ id: session.id, providerID: pID, @@ -1134,6 +1214,7 @@ export const useSessionUIStore = create()((set, get) => ({ text: assistantPlanText, prefaceText: EXECUTION_FORK_META_TEXT, agent: currentAgentName ?? undefined, + directory: sessionDirectory, }) }, @@ -1238,3 +1319,7 @@ export const useSessionUIStore = create()((set, get) => ({ return get().sessionPlanAvailable.get(sessionId) ?? false }, })) + +setSessionOpener((sessionID, directory) => { + useSessionUIStore.getState().setCurrentSession(sessionID, directory) +}) diff --git a/packages/ui/src/sync/streaming.ts b/packages/ui/src/sync/streaming.ts index 7a1d49bf..62bbe9dd 100644 --- a/packages/ui/src/sync/streaming.ts +++ b/packages/ui/src/sync/streaming.ts @@ -31,6 +31,13 @@ export const useStreamingStore = create()(() => ({ messageStreamStates: new Map(), })) +export function resetStreamingState() { + useStreamingStore.setState({ + streamingMessageIds: new Map(), + messageStreamStates: new Map(), + }) +} + /** * Called from the SyncBridge/flush handler when child store state changes. * Derives streaming state from session_status + messages. diff --git a/packages/ui/src/sync/submit.ts b/packages/ui/src/sync/submit.ts index 943f9e66..35a1ceb8 100644 --- a/packages/ui/src/sync/submit.ts +++ b/packages/ui/src/sync/submit.ts @@ -1,7 +1,7 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client" import { useCallback } from "react" -import { useSyncSDK } from "./sync-context" -import { useDirectoryStore } from "./sync-context" +import { opencodeClient } from "@/lib/opencode/client" +import { useDirectoryStore, useSyncDirectory } from "./sync-context" import { useSync } from "./use-sync" // --------------------------------------------------------------------------- @@ -33,8 +33,8 @@ export type SubmitInput = { } export function usePromptSubmit() { - const sdk = useSyncSDK() const store = useDirectoryStore() + const directory = useSyncDirectory() const sync = useSync() const submit = useCallback( @@ -82,41 +82,31 @@ export function usePromptSubmit() { try { if (input.command) { // Slash command - await sdk.session.command({ - sessionID: input.sessionID, - command: input.command.name, - arguments: input.command.arguments, + await opencodeClient.sendCommand({ + id: input.sessionID, + command: input.command?.name ?? "", + arguments: input.command?.arguments ?? "", agent: input.agent, - model: `${input.model.providerID}/${input.model.modelID}`, + providerID: input.model.providerID, + modelID: input.model.modelID, variant: input.variant, - parts: input.images, - }) + files: input.images, + messageId: messageID, + directory, + }).then(() => undefined) } else { // Regular prompt - const requestParts: Array<{ id: string; type: "text"; text: string } - | { id: string; type: "file"; mime: string; url: string; filename?: string }> = [ - { id: textPart.id, type: "text" as const, text: input.text }, - ] - if (input.images) { - for (const img of input.images) { - requestParts.push({ - id: img.id ?? ascending("part"), - type: "file" as const, - mime: img.mime, - url: img.url, - filename: img.filename, - }) - } - } - - await sdk.session.promptAsync({ - sessionID: input.sessionID, + await opencodeClient.sendMessage({ + id: input.sessionID, agent: input.agent, - model: input.model, - messageID, - parts: requestParts, + providerID: input.model.providerID, + modelID: input.model.modelID, + messageId: messageID, + text: input.text, + files: input.images, variant: input.variant, - }) + directory, + }).then(() => undefined) } return true } catch (error) { @@ -136,7 +126,7 @@ export function usePromptSubmit() { throw error } }, - [sdk, store, sync], + [directory, store, sync], ) return submit diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 0d0dfff4..7ff32fc1 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -39,6 +39,10 @@ import type { PermissionRequest } from "@/types/permission" import type { QuestionRequest } from "@/types/question" import * as sessionActions from "./session-actions" import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization" +import { openSessionFromToast } from "./session-navigation" +import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory" +import { getRuntimeKey } from "@/lib/runtime-switch" +import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" import { setSessionPrefetch } from "./session-prefetch-cache" // --------------------------------------------------------------------------- @@ -60,6 +64,34 @@ const syncGlobal = globalThis as SyncGlobal const SyncContext = syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] ?? createContext(null) syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] = SyncContext +type SdkResult = { + data?: T + error?: unknown + response?: { + status?: number + headers?: { get?: (name: string) => string | null } + } +} + +function formatSdkError(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === "string") return error + if (error && typeof error === "object" && "message" in error && typeof (error as { message?: unknown }).message === "string") { + return (error as { message: string }).message + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function assertSdkSuccess(result: SdkResult, operation: string): T | undefined { + if (!result.error) return result.data + const status = result.response?.status + throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) +} + function useSyncSystem() { const ctx = useContext(SyncContext) if (!ctx) throw new Error("useSyncSystem must be used within ") @@ -153,6 +185,7 @@ export function useAllLiveSessions(): Session[] { // Boot debounce — suppresses redundant refresh/re-bootstrap events during startup. let bootingRoot = false let bootedAt = 0 +let globalBootstrapGeneration = 0 const BOOT_DEBOUNCE_MS = 1500 const RECONNECT_MESSAGE_LIMIT = 30 const SESSION_MATERIALIZATION_MESSAGE_LIMIT = 30 @@ -225,9 +258,11 @@ async function materializeSessionFromServer( store: StoreApi, ) { const scopedClient = opencodeClient.getScopedSdkClient(directory) - const result = await retry(() => - scopedClient.session.messages({ sessionID, limit: SESSION_MATERIALIZATION_MESSAGE_LIMIT }), - ) + const result = await retry(async () => { + const response = await scopedClient.session.messages({ sessionID, limit: SESSION_MATERIALIZATION_MESSAGE_LIMIT }) + assertSdkSuccess(response, "session.messages") + return response + }) const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id) if (records.length === 0) return const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined @@ -282,12 +317,56 @@ const getPermissionToastKey = (sessionID?: string, requestID?: string) => { return `${sessionID}:${requestID}` } -const openSessionFromToast = (sessionID: string, directory: string) => { - void import("./session-ui-store") - .then(({ useSessionUIStore }) => { - useSessionUIStore.getState().setCurrentSession(sessionID, directory) - }) - .catch(() => undefined) +type UiNotificationPayload = { + title?: unknown + body?: unknown + tag?: unknown + kind?: unknown + sessionId?: unknown + directory?: unknown + requireHidden?: unknown + desktopStdoutActive?: unknown +} + +const asOptionalString = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): boolean => { + if ((payload as { type?: unknown }).type !== "openchamber:notification") { + return false + } + + const properties = (payload as { properties?: unknown }).properties + if (!properties || typeof properties !== "object") { + return true + } + + const notification = properties as UiNotificationPayload + if (notification.desktopStdoutActive === true && getRuntimeKey() === "local") { + return true + } + + const notifications = getRegisteredRuntimeAPIs()?.notifications + if (!notifications?.notifyAgentCompletion) { + return true + } + + void notifications.notifyAgentCompletion({ + title: asOptionalString(notification.title), + body: asOptionalString(notification.body), + tag: asOptionalString(notification.tag), + kind: asOptionalString(notification.kind), + sessionId: asOptionalString(notification.sessionId), + directory: asOptionalString(notification.directory) ?? (fallbackDirectory && fallbackDirectory !== "global" ? fallbackDirectory : undefined), + requireHidden: notification.requireHidden === true, + }).catch((error) => { + console.warn("[notifications] failed to dispatch UI notification", error) + }) + + return true } export function setActiveSession(directory: string, sessionId: string) { @@ -955,16 +1034,26 @@ export async function resyncBlockingRequestsForDirectory( const autoAcceptingSessionIds = Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId)) if (autoAcceptingSessionIds.length > 0) { - await Promise.all( - autoAcceptingSessionIds.flatMap((sessionId) => - (grouped[sessionId] ?? []).map((permission) => - sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined), - ), - ), - ) + const acceptedIdsBySession = new Map>() + await Promise.all(autoAcceptingSessionIds.flatMap((sessionId) => + (grouped[sessionId] ?? []).map(async (permission) => { + try { + await sessionActions.respondToPermission(permission.sessionID, permission.id, "once") + const accepted = acceptedIdsBySession.get(sessionId) ?? new Set() + accepted.add(permission.id) + acceptedIdsBySession.set(sessionId, accepted) + } catch { + // Keep failed auto-accept permissions in UI state so the user can act. + } + }), + )) for (const sessionId of autoAcceptingSessionIds) { - delete grouped[sessionId] + const acceptedIds = acceptedIdsBySession.get(sessionId) + if (!acceptedIds) continue + const remaining = (grouped[sessionId] ?? []).filter((permission) => !acceptedIds.has(permission.id)) + if (remaining.length > 0) grouped[sessionId] = remaining + else delete grouped[sessionId] } } @@ -1024,8 +1113,16 @@ async function resyncDirectoryAfterReconnect( const scopedClient = opencodeClient.getScopedSdkClient(directory) await Promise.all(candidateSessionIds.map(async (sessionId) => { const [sessionResponse, messageResponse] = await Promise.all([ - scopedClient.session.get({ sessionID: sessionId }).catch(() => null), - scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT }).catch(() => null), + retry(async () => { + const response = await scopedClient.session.get({ sessionID: sessionId }) + assertSdkSuccess(response, "session.get") + return response + }).catch(() => null), + retry(async () => { + const response = await scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT }) + assertSdkSuccess(response, "session.messages") + return response + }).catch(() => null), ]) const session = sessionResponse?.data const records = messageResponse?.data @@ -1104,6 +1201,10 @@ function handleEvent( ) { const directory = resolveDirectoryFromRoutingIndex(routingIndex, rawDirectory, payload, childStores) + if (handleUiNotificationEvent(payload, directory)) { + return + } + // Global events if (directory === "global" || !directory) { const recent = isRecentBoot() @@ -1177,7 +1278,6 @@ function handleEvent( if (permissionStore.isSessionAutoAccepting(permission.sessionID)) { updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined) - return } const toastKey = getPermissionToastKey(permission.sessionID, permission.id) @@ -1528,15 +1628,29 @@ export function SyncProvider(props: { // Bootstrap global state — set bootingRoot/bootedAt to suppress // redundant refresh events during startup useEffect(() => { + const generation = ++globalBootstrapGeneration bootingRoot = true const globalActions = useGlobalSyncStore.getState().actions - bootstrapGlobal(props.sdk, globalActions.set) + bootstrapGlobal(props.sdk, (patch) => { + if (globalBootstrapGeneration === generation) { + globalActions.set(patch) + } + }) .then(() => { - bootedAt = Date.now() + if (globalBootstrapGeneration === generation) { + bootedAt = Date.now() + } }) .finally(() => { - bootingRoot = false + if (globalBootstrapGeneration === generation) { + bootingRoot = false + } }) + return () => { + if (globalBootstrapGeneration === generation) { + bootingRoot = false + } + } }, [props.sdk]) // Event pipeline — created once per mount. No class, no start/stop. @@ -1690,10 +1804,36 @@ export function SyncProvider(props: { // Ensure current directory's child store exists useEffect(() => { + let seedExpiryTimer: ReturnType | undefined if (props.directory) { const store = childStores.ensureChild(props.directory) + const statusSeed = getRuntimeLiveStatusSeed(getRuntimeKey(), props.directory) + if (statusSeed) { + store.setState((state: DirectoryStore) => ({ + session_status: { + ...state.session_status, + [statusSeed.sessionId]: state.session_status[statusSeed.sessionId] ?? statusSeed.status, + }, + })) + seedExpiryTimer = setTimeout(() => { + store.setState((state: DirectoryStore) => { + if (state.session_status[statusSeed.sessionId] !== statusSeed.status) { + return state + } + return { + session_status: { + ...state.session_status, + [statusSeed.sessionId]: { type: "idle" as const }, + }, + } + }) + }, LIVE_STATUS_TTL_MS) + } ingestDirectoryStateIntoRoutingIndex(routingIndex, props.directory, store.getState()) } + return () => { + if (seedExpiryTimer) clearTimeout(seedExpiryTimer) + } }, [props.directory, childStores, routingIndex]) // Set refs so non-React code (session-actions, session-ui-store) can access sync state @@ -1713,6 +1853,7 @@ export function SyncProvider(props: { if (!props.directory) return const store = childStores.getChild(props.directory) if (!store) return + updateStreamingState(store.getState()) const unsubscribe = store.subscribe((state) => { updateStreamingState(state) }) @@ -2341,7 +2482,9 @@ export function useSessionMessageRecords( const _ensureMessagesLoading = new Set() export function useEnsureSessionMessages(sessionID: string, directory?: string) { - const store = useDirectoryStore(directory) + const syncDirectory = useSyncDirectory() + const resolvedDirectory = directory ?? syncDirectory + const store = useDirectoryStore(resolvedDirectory) React.useEffect(() => { if (!sessionID) return @@ -2352,8 +2495,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string) // Session doesn't exist — nothing to load if (!state.session.some((s) => s.id === sessionID)) return - const dir = directory ?? opencodeClient.getDirectory() - const loadingKey = `${dir ?? ""}:${sessionID}` + const loadingKey = `${resolvedDirectory}:${sessionID}` // Already loading this session for this directory if (_ensureMessagesLoading.has(loadingKey)) return @@ -2361,14 +2503,14 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string) void (async () => { try { - await materializeSessionFromServer(dir ?? "", sessionID, store) + await materializeSessionFromServer(resolvedDirectory, sessionID, store) } catch { // Transient failure — next navigation or reconnect will retry } finally { _ensureMessagesLoading.delete(loadingKey) } })() - }, [sessionID, store, directory]) + }, [sessionID, store, resolvedDirectory]) } /** diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 931413c4..91509833 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -47,6 +47,35 @@ type SyncMeta = { loading: boolean } +type SdkResult = { + data?: T + error?: unknown + response?: { + status?: number + headers?: { get?: (name: string) => string | null } + } +} + +function formatSdkError(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === "string") return error + if (error && typeof error === "object") { + const message = (error as { message?: unknown }).message + if (typeof message === "string" && message.length > 0) return message + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function assertSdkSuccess(result: SdkResult, operation: string): void { + if (!result.error) return + const status = result.response?.status + throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) +} + const isConstrainedSessionRuntime = () => isVSCodeRuntime() || isMobileSurfaceRuntime() const getConstrainedInitialPageExpansionMax = () => VSCODE_INITIAL_PAGE_EXPANSION_LIMITS[VSCODE_INITIAL_PAGE_EXPANSION_LIMITS.length - 1] const getEffectiveSessionCacheLimit = () => { @@ -267,9 +296,11 @@ export function useSync() { // Fetch messages from API const fetchMessages = useCallback( async (sessionID: string, limit: number, before?: string) => { - const result = await retry(() => - sdk.session.messages({ sessionID, directory, limit, before }), - ) + const result = await retry(async () => { + const response = await sdk.session.messages({ sessionID, directory, limit, before }) + assertSdkSuccess(response, "session.messages") + return response + }) const items = (result.data ?? []).filter((x: { info?: { id?: string } }) => !!x?.info?.id) const session = items .map((x: { info: Message }) => stripMessageDiffSnapshots(x.info)) @@ -397,7 +428,11 @@ export function useSync() { shouldFetchSession ? (async () => { try { - const result = await retry(() => sdk.session.get({ sessionID, directory })) + const result = await retry(async () => { + const response = await sdk.session.get({ sessionID, directory }) + assertSdkSuccess(response, "session.get") + return response + }) if (result.data) { const s = store.getState() const sessions = [...s.session] diff --git a/packages/ui/src/sync/viewport-store.ts b/packages/ui/src/sync/viewport-store.ts index cf136fe5..1d072976 100644 --- a/packages/ui/src/sync/viewport-store.ts +++ b/packages/ui/src/sync/viewport-store.ts @@ -4,6 +4,7 @@ */ import { create } from "zustand" +import { getRuntimeKey } from "@/lib/runtime-switch" export type SessionMemoryState = { viewportAnchor: number @@ -36,6 +37,13 @@ export type ViewportState = { updateViewportAnchor: (sessionId: string, anchor: number, scrollPosition?: SessionMemoryState['scrollPosition']) => void } +export const viewportSessionKey = (sessionId: string, runtimeKey = getRuntimeKey()): string => `${runtimeKey}\n${sessionId}` + +export const getViewportSessionMemory = (sessionId: string): SessionMemoryState | undefined => { + const state = useViewportStore.getState() + return state.sessionMemoryState.get(viewportSessionKey(sessionId)) ?? state.sessionMemoryState.get(sessionId) +} + export const useViewportStore = create()((set) => ({ sessionMemoryState: new Map(), isSyncing: false, @@ -43,13 +51,14 @@ export const useViewportStore = create()((set) => ({ updateViewportAnchor: (sessionId, anchor, scrollPosition) => set((s) => { const map = new Map(s.sessionMemoryState) - const existing = map.get(sessionId) ?? { + const key = viewportSessionKey(sessionId) + const existing = map.get(key) ?? map.get(sessionId) ?? { viewportAnchor: 0, isStreaming: false, lastAccessedAt: Date.now(), backgroundMessageCount: 0, } - map.set(sessionId, { + map.set(key, { ...existing, viewportAnchor: anchor, ...(scrollPosition ? { scrollPosition } : {}), diff --git a/packages/ui/src/types/zumer-snapdom.d.ts b/packages/ui/src/types/zumer-snapdom.d.ts new file mode 100644 index 00000000..ec63924d --- /dev/null +++ b/packages/ui/src/types/zumer-snapdom.d.ts @@ -0,0 +1,22 @@ +declare module '@zumer/snapdom' { + export type SnapdomOptions = { + backgroundColor?: string; + cache?: 'disabled' | boolean | string; + dpr?: number; + embedFonts?: boolean; + fast?: boolean; + height?: number; + outerShadows?: boolean; + outerTransforms?: boolean; + placeholders?: boolean; + plugins?: unknown[]; + quality?: number; + width?: number; + }; + + export type SnapdomCapture = { + toCanvas(): Promise; + }; + + export function snapdom(element: Element, options?: SnapdomOptions): Promise; +} diff --git a/packages/vscode/src/bridge-config-runtime.test.js b/packages/vscode/src/bridge-config-runtime.test.js new file mode 100644 index 00000000..fef9b3de --- /dev/null +++ b/packages/vscode/src/bridge-config-runtime.test.js @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +mock.module('vscode', () => ({ + workspace: { + workspaceFolders: [], + getConfiguration: () => ({ get: () => undefined }), + }, +})); + +const { handleConfigBridgeMessage } = await import('./bridge-config-runtime.ts'); + +const tempRoots = []; +const originalOpencodeConfig = process.env.OPENCODE_CONFIG; + +const createCtx = (workingDirectory, restartImpl = async () => undefined) => { + const restart = mock(restartImpl); + return { + restart, + manager: { + getWorkingDirectory: () => workingDirectory, + restart, + }, + }; +}; + +const deps = { + readSettings: () => ({}), + persistSettings: async (changes) => changes, + readMagicPromptOverrides: () => ({ version: 1, overrides: {} }), + saveMagicPromptOverride: async () => ({ version: 1, overrides: {} }), + resetMagicPromptOverride: async () => ({ version: 1, overrides: {} }), + resetAllMagicPromptOverrides: async () => ({ version: 1, overrides: {} }), + fetchOpenCodeSkillsFromApi: async () => null, + clientReloadDelayMs: 800, +}; + +afterEach(() => { + if (originalOpencodeConfig === undefined) { + delete process.env.OPENCODE_CONFIG; + } else { + process.env.OPENCODE_CONFIG = originalOpencodeConfig; + } + + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')); + +describe('VS Code config bridge plugin parity', () => { + test('creates, lists, updates, and deletes project plugin entries', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugins-')); + tempRoots.push(root); + const ctx = createCtx(root); + + const created = await handleConfigBridgeMessage({ + id: 'create', + type: 'api:config/plugins', + payload: { + method: 'POST', + target: 'entry', + directory: root, + body: { scope: 'project', spec: 'plugin-a', options: { enabled: true } }, + }, + }, ctx, deps); + + expect(created?.success).toBe(true); + expect(ctx.restart).toHaveBeenCalledTimes(1); + + const listed = await handleConfigBridgeMessage({ + id: 'list', + type: 'api:config/plugins', + payload: { method: 'GET', target: 'list', directory: root }, + }, ctx, deps); + const entries = listed?.data?.entries || []; + const entry = entries.find((candidate) => candidate.spec === 'plugin-a'); + expect(entry?.scope).toBe('project'); + + const updated = await handleConfigBridgeMessage({ + id: 'update', + type: 'api:config/plugins', + payload: { + method: 'PATCH', + target: 'entry', + directory: root, + pluginId: entry?.id, + body: { spec: 'plugin-b' }, + }, + }, ctx, deps); + expect(updated?.success).toBe(true); + + const config = JSON.parse(fs.readFileSync(path.join(root, '.opencode', 'opencode.json'), 'utf8')); + expect(config.plugin).toEqual([['plugin-b', { enabled: true }]]); + + const relisted = await handleConfigBridgeMessage({ + id: 'relist', + type: 'api:config/plugins', + payload: { method: 'GET', target: 'list', directory: root }, + }, ctx, deps); + const updatedEntry = (relisted?.data?.entries || []).find((candidate) => candidate.spec === 'plugin-b'); + + const deleted = await handleConfigBridgeMessage({ + id: 'delete', + type: 'api:config/plugins', + payload: { method: 'DELETE', target: 'entry', directory: root, pluginId: updatedEntry?.id }, + }, ctx, deps); + expect(deleted?.success).toBe(true); + }); + + test('creates and reads project plugin files', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-files-')); + tempRoots.push(root); + const ctx = createCtx(root); + + const created = await handleConfigBridgeMessage({ + id: 'create-file', + type: 'api:config/plugins', + payload: { + method: 'POST', + target: 'file', + directory: root, + body: { scope: 'project', fileName: 'demo-plugin.ts', content: 'export default {}' }, + }, + }, ctx, deps); + expect(created?.success).toBe(true); + + const listed = await handleConfigBridgeMessage({ + id: 'list', + type: 'api:config/plugins', + payload: { method: 'GET', target: 'list', directory: root }, + }, ctx, deps); + const files = listed?.data?.files || []; + const file = files.find((candidate) => candidate.fileName === 'demo-plugin.ts'); + expect(file?.scope).toBe('project'); + + const read = await handleConfigBridgeMessage({ + id: 'read-file', + type: 'api:config/plugins', + payload: { method: 'GET', target: 'file', directory: root, pluginId: file?.id }, + }, ctx, deps); + expect(read?.data).toEqual({ fileName: 'demo-plugin.ts', scope: 'project', content: 'export default {}' }); + }); + + test('updates and deletes user plugin entries from OPENCODE_CONFIG source', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-custom-config-')); + tempRoots.push(root); + const configDir = path.join(root, 'custom-config'); + const configPath = path.join(configDir, 'opencode.json'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ plugin: ['custom-plugin'] }, null, 2), 'utf8'); + process.env.OPENCODE_CONFIG = configPath; + const ctx = createCtx(root); + + const listed = await handleConfigBridgeMessage({ + id: 'list-custom', + type: 'api:config/plugins', + payload: { method: 'GET', target: 'list', directory: root }, + }, ctx, deps); + const entry = (listed?.data?.entries || []).find((candidate) => candidate.spec === 'custom-plugin'); + expect(entry?.scope).toBe('user'); + + const updated = await handleConfigBridgeMessage({ + id: 'update-custom', + type: 'api:config/plugins', + payload: { + method: 'PATCH', + target: 'entry', + directory: root, + pluginId: entry?.id, + body: { spec: 'custom-plugin-next' }, + }, + }, ctx, deps); + expect(updated?.success).toBe(true); + expect(readJson(configPath).plugin).toEqual(['custom-plugin-next']); + + const relisted = await handleConfigBridgeMessage({ + id: 'relist-custom', + type: 'api:config/plugins', + payload: { method: 'GET', target: 'list', directory: root }, + }, ctx, deps); + const updatedEntry = (relisted?.data?.entries || []).find((candidate) => candidate.spec === 'custom-plugin-next'); + + const deleted = await handleConfigBridgeMessage({ + id: 'delete-custom', + type: 'api:config/plugins', + payload: { method: 'DELETE', target: 'entry', directory: root, pluginId: updatedEntry?.id }, + }, ctx, deps); + expect(deleted?.success).toBe(true); + expect(readJson(configPath).plugin).toBeUndefined(); + }); + + test('writes user plugin files next to OPENCODE_CONFIG', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-custom-files-')); + tempRoots.push(root); + const configDir = path.join(root, 'custom-config'); + const configPath = path.join(configDir, 'opencode.json'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, '{}', 'utf8'); + process.env.OPENCODE_CONFIG = configPath; + const ctx = createCtx(root); + + const created = await handleConfigBridgeMessage({ + id: 'create-custom-file', + type: 'api:config/plugins', + payload: { + method: 'POST', + target: 'file', + directory: root, + body: { scope: 'user', fileName: 'demo-plugin.ts', content: 'export default {}' }, + }, + }, ctx, deps); + + expect(created?.success).toBe(true); + expect(fs.readFileSync(path.join(configDir, 'plugins', 'demo-plugin.ts'), 'utf8')).toBe('export default {}'); + }); + + test('reports plugin mutation success when restart fails after writing config', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-restart-')); + tempRoots.push(root); + const ctx = createCtx(root, async () => { + throw new Error('restart failed'); + }); + + const created = await handleConfigBridgeMessage({ + id: 'create-restart-failure', + type: 'api:config/plugins', + payload: { + method: 'POST', + target: 'entry', + directory: root, + body: { scope: 'project', spec: 'plugin-restart' }, + }, + }, ctx, deps); + + expect(created?.success).toBe(true); + expect(created?.data).toMatchObject({ success: true, requiresReload: false, reloadFailed: true }); + expect(created?.data?.warning).toContain('restart failed'); + expect(readJson(path.join(root, '.opencode', 'opencode.json')).plugin).toEqual(['plugin-restart']); + }); +}); diff --git a/packages/vscode/src/bridge-config-runtime.ts b/packages/vscode/src/bridge-config-runtime.ts index 8523fad3..dcc9cfdf 100644 --- a/packages/vscode/src/bridge-config-runtime.ts +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -5,12 +5,16 @@ import * as path from 'path'; import { createAgent, createCommand, + createSnippet, deleteAgent, deleteCommand, + deleteSnippet, getAgentSources, getCommandSources, + getSnippet, updateAgent, updateCommand, + updateSnippet, type AgentScope, type CommandScope, AGENT_SCOPE, @@ -28,10 +32,23 @@ import { type DiscoveredSkill, SKILL_SCOPE, listMcpConfigs, + listPluginDirFiles, + listPluginEntries, + getPluginEntry, + createPluginEntry, + updatePluginEntry, + deletePluginEntry, + readPluginDirFile, + writePluginDirFile, + deletePluginDirFile, + queryPluginRegistry, + listSnippets, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig, + expandSnippets, + type SnippetScope, } from './opencodeConfig'; import { getSkillsCatalog, @@ -67,6 +84,32 @@ const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: str : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath) ); +const pluginMutationPayload = async ( + ctx: BridgeContext | undefined, + deps: ConfigRuntimeDeps, + label: string, +) => { + try { + await ctx?.manager?.restart(); + return { + success: true, + requiresReload: true, + message: `${label}. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + reloadFailed: false, + }; + } catch (error) { + return { + success: true, + requiresReload: false, + message: `${label}, but OpenCode reload failed.`, + reloadDelayMs: deps.clientReloadDelayMs, + reloadFailed: true, + warning: error instanceof Error ? error.message : String(error), + }; + } +}; + const parseSkillsCatalogSources = (settings: Record): SkillsCatalogSourceConfig[] => { const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs; if (!Array.isArray(rawCatalogs)) { @@ -462,6 +505,144 @@ export async function handleConfigBridgeMessage( return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } + case 'api:config/plugins': { + const { method, target, pluginId, body, directory, specs, refresh } = (payload || {}) as { + method?: string; + target?: 'list' | 'registry' | 'entry' | 'file'; + pluginId?: string; + body?: Record; + directory?: string; + specs?: string[]; + refresh?: boolean; + }; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + const workingDirectory = resolveWorkingDirectory(ctx, directory); + + if ((target === 'list' || !target) && normalizedMethod === 'GET') { + return { + id, + type, + success: true, + data: { + entries: listPluginEntries(workingDirectory), + files: listPluginDirFiles(workingDirectory), + }, + }; + } + + if (target === 'registry' && normalizedMethod === 'GET') { + const data = await queryPluginRegistry(Array.isArray(specs) ? specs : [], { + refresh: refresh === true, + workingDirectory, + }); + return { id, type, success: true, data }; + } + + if (target === 'entry') { + if (normalizedMethod === 'GET') { + if (!pluginId) return { id, type, success: false, error: 'Plugin entry id is required' }; + const entry = getPluginEntry(pluginId, workingDirectory); + if (!entry) return { id, type, success: false, error: 'Plugin entry not found' }; + return { id, type, success: true, data: entry }; + } + if (normalizedMethod === 'POST') { + createPluginEntry(body || {}, workingDirectory); + } else if (normalizedMethod === 'PATCH') { + if (!pluginId) return { id, type, success: false, error: 'Plugin entry id is required' }; + updatePluginEntry(pluginId, body || {}, workingDirectory); + } else if (normalizedMethod === 'DELETE') { + if (!pluginId) return { id, type, success: false, error: 'Plugin entry id is required' }; + deletePluginEntry(pluginId, workingDirectory); + } else { + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + return { + id, + type, + success: true, + data: await pluginMutationPayload(ctx, deps, 'Plugin entry changed'), + }; + } + + if (target === 'file') { + if (normalizedMethod === 'GET') { + if (!pluginId) return { id, type, success: false, error: 'Plugin file id is required' }; + const file = readPluginDirFile(pluginId, workingDirectory); + if (!file) return { id, type, success: false, error: 'Plugin file not found' }; + return { id, type, success: true, data: file }; + } + if (normalizedMethod === 'POST') { + writePluginDirFile(body || {}, workingDirectory); + } else if (normalizedMethod === 'PUT') { + if (!pluginId) return { id, type, success: false, error: 'Plugin file id is required' }; + const existing = readPluginDirFile(pluginId, workingDirectory); + if (!existing) return { id, type, success: false, error: 'Plugin file not found' }; + writePluginDirFile({ fileName: existing.fileName, scope: existing.scope, content: body?.content }, workingDirectory, { overwrite: true }); + } else if (normalizedMethod === 'DELETE') { + if (!pluginId) return { id, type, success: false, error: 'Plugin file id is required' }; + deletePluginDirFile(pluginId, workingDirectory); + } else { + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + return { + id, + type, + success: true, + data: await pluginMutationPayload(ctx, deps, 'Plugin file changed'), + }; + } + + return { id, type, success: false, error: 'Unsupported plugin config request' }; + } + + case 'api:config/snippets': { + const { method, name, body, directory } = (payload || {}) as { + method?: string; + name?: string; + body?: Record; + directory?: string; + }; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + const snippetName = typeof name === 'string' ? name.trim() : ''; + const workingDirectory = resolveWorkingDirectory(ctx, directory); + + if (normalizedMethod === 'GET' && !snippetName) { + return { id, type, success: true, data: listSnippets(workingDirectory) }; + } + + if (normalizedMethod === 'POST' && !snippetName) { + return { id, type, success: true, data: { text: expandSnippets(typeof body?.text === 'string' ? body.text : '', workingDirectory) } }; + } + + if (!snippetName) { + return { id, type, success: false, error: 'Snippet name is required' }; + } + + if (normalizedMethod === 'GET') { + const snippet = getSnippet(snippetName, workingDirectory); + if (!snippet) return { id, type, success: false, error: `Snippet "${snippetName}" not found` }; + return { id, type, success: true, data: snippet }; + } + + if (normalizedMethod === 'POST') { + const scope = body?.scope === 'project' ? 'project' : 'global'; + const snippet = createSnippet(snippetName, (body || {}) as Record, workingDirectory, scope as SnippetScope); + return { id, type, success: true, data: { success: true, snippet } }; + } + + if (normalizedMethod === 'PATCH') { + const snippet = updateSnippet(snippetName, (body || {}) as Record, workingDirectory); + return { id, type, success: true, data: { success: true, snippet } }; + } + + if (normalizedMethod === 'DELETE') { + deleteSnippet(snippetName, workingDirectory); + return { id, type, success: true, data: { success: true } }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + case 'api:config/skills': { const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; diff --git a/packages/vscode/src/bridge-git-special-runtime.test.js b/packages/vscode/src/bridge-git-special-runtime.test.js new file mode 100644 index 00000000..cd29470a --- /dev/null +++ b/packages/vscode/src/bridge-git-special-runtime.test.js @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, mock } from 'bun:test'; + +const gitService = { + getGitRangeFiles: mock(), + getGitRangeDiff: mock(), +}; + +const sdkClient = { + v2: { + model: { + list: mock(), + }, + }, + session: { + create: mock(), + promptAsync: mock(), + messages: mock(), + delete: mock(), + }, +}; + +const createOpencodeClient = mock(() => sdkClient); +const rawFetch = mock(async () => { + throw new Error('raw fetch should not be used'); +}); + +mock.module('./gitService', () => gitService); +mock.module('@opencode-ai/sdk/v2', () => ({ createOpencodeClient })); + +const { handleSpecialGitBridgeMessage } = await import('./bridge-git-special-runtime'); + +describe('bridge git special runtime', () => { + beforeEach(() => { + gitService.getGitRangeFiles.mockReset(); + gitService.getGitRangeDiff.mockReset(); + sdkClient.v2.model.list.mockReset(); + sdkClient.session.create.mockReset(); + sdkClient.session.promptAsync.mockReset(); + sdkClient.session.messages.mockReset(); + sdkClient.session.delete.mockReset(); + createOpencodeClient.mockReset(); + rawFetch.mockClear(); + + globalThis.fetch = rawFetch; + createOpencodeClient.mockImplementation(() => sdkClient); + gitService.getGitRangeFiles.mockImplementation(async () => ['src/a.ts']); + gitService.getGitRangeDiff.mockImplementation(async () => ({ diff: 'diff --git a/src/a.ts b/src/a.ts\n+new line' })); + sdkClient.v2.model.list.mockImplementation(async () => ({ + data: [{ providerID: 'anthropic', id: 'claude-sonnet-4-5' }], + error: undefined, + })); + sdkClient.session.create.mockImplementation(async () => ({ + data: { id: 'ses_1' }, + error: undefined, + })); + sdkClient.session.promptAsync.mockImplementation(async () => ({ data: true, error: undefined })); + sdkClient.session.messages.mockImplementation(async () => ({ + data: [{ + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'text', text: '{"title":"PR title","body":"PR body"}' }], + }], + error: undefined, + })); + sdkClient.session.delete.mockImplementation(async () => ({ data: true, error: undefined })); + }); + + it('generates PR descriptions through the OpenCode SDK session flow', async () => { + const response = await handleSpecialGitBridgeMessage({ + id: '1', + type: 'api:git/pr-description', + payload: { + directory: '/repo', + base: 'main', + head: 'feature', + providerId: 'anthropic', + modelId: 'claude-sonnet-4-5', + }, + }, { + manager: { + getApiUrl: () => 'http://opencode.test', + getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test' }), + }, + }, { + readSettings: () => ({}), + execGit: mock(), + }); + + expect(response).toEqual({ + id: '1', + type: 'api:git/pr-description', + success: true, + data: { title: 'PR title', body: 'PR body' }, + }); + expect(rawFetch).not.toHaveBeenCalled(); + expect(createOpencodeClient).toHaveBeenCalledWith({ + baseUrl: 'http://opencode.test', + headers: { Authorization: 'Bearer test' }, + }); + expect(sdkClient.v2.model.list).toHaveBeenCalled(); + expect(sdkClient.session.create).toHaveBeenCalledWith({ + directory: '/repo', + title: 'Git Generation', + }, expect.objectContaining({ signal: expect.any(AbortSignal) })); + expect(sdkClient.session.promptAsync).toHaveBeenCalledWith(expect.objectContaining({ + sessionID: 'ses_1', + directory: '/repo', + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }, + }), expect.objectContaining({ signal: expect.any(AbortSignal) })); + expect(sdkClient.session.messages).toHaveBeenCalledWith({ + sessionID: 'ses_1', + directory: '/repo', + limit: 10, + }, expect.objectContaining({ signal: expect.any(AbortSignal) })); + expect(sdkClient.session.delete).toHaveBeenCalledWith({ sessionID: 'ses_1' }, expect.objectContaining({ signal: expect.any(AbortSignal) })); + }); +}); diff --git a/packages/vscode/src/bridge-git-special-runtime.ts b/packages/vscode/src/bridge-git-special-runtime.ts index 6fc60e51..2169b613 100644 --- a/packages/vscode/src/bridge-git-special-runtime.ts +++ b/packages/vscode/src/bridge-git-special-runtime.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import * as gitService from './gitService'; import type { BridgeContext, BridgeResponse } from './bridge'; @@ -28,6 +29,48 @@ const sleep = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); +type BridgeSdkResult = { + data?: T; + error?: unknown; + response?: { status?: number }; +}; + +const formatBridgeSdkError = (error: unknown): string => { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string') { + return (error as { message: string }).message; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +}; + +const unwrapBridgeSdkData = (result: BridgeSdkResult, operation: string): T => { + if (result.error) { + const status = result.response?.status; + throw new Error(`${operation} failed${status ? ` (${status})` : ''}: ${formatBridgeSdkError(result.error)}`); + } + if (result.data === undefined || result.data === null) { + throw new Error(`${operation} failed: empty response`); + } + return result.data; +}; + +const assertBridgeSdkSuccess = (result: BridgeSdkResult, operation: string): void => { + if (result.error) { + const status = result.response?.status; + throw new Error(`${operation} failed${status ? ` (${status})` : ''}: ${formatBridgeSdkError(result.error)}`); + } +}; + +const createBridgeGitClient = (apiUrl: string, authHeaders?: Record) => createOpencodeClient({ + baseUrl: apiUrl.replace(/\/+$/, ''), + headers: authHeaders || {}, +}); + const readStringField = (value: unknown, key: string): string => { if (!value || typeof value !== 'object') return ''; const record = value as Record; @@ -44,22 +87,11 @@ const fetchBridgeGitModelCatalog = async ( return bridgeGitModelCatalogCache; } - const headers = authHeaders || {}; - const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`); - const response = await fetch(modelsUrl.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...headers, - }, - signal: AbortSignal.timeout(8_000), - }); - - if (!response.ok) { - throw new Error('Failed to fetch model catalog'); - } - - const payload = await response.json().catch(() => null) as unknown; + const client = createBridgeGitClient(apiUrl, authHeaders); + const payload = unwrapBridgeSdkData( + await client.v2.model.list(undefined, { signal: AbortSignal.timeout(8_000) }), + 'model.list' + ); const refs = new Set(); if (Array.isArray(payload)) { for (const item of payload) { @@ -68,7 +100,9 @@ const fetchBridgeGitModelCatalog = async ( } const record = item as Record; const providerID = typeof record.providerID === 'string' ? record.providerID.trim() : ''; - const modelID = typeof record.modelID === 'string' ? record.modelID.trim() : ''; + const modelID = typeof record.id === 'string' + ? record.id.trim() + : (typeof record.modelID === 'string' ? record.modelID.trim() : ''); if (providerID && modelID) { refs.add(`${providerID}/${modelID}`); } @@ -153,33 +187,19 @@ const generateBridgeTextWithSessionFlow = async ({ modelID: string; authHeaders?: Record; }): Promise => { - const headers = authHeaders || {}; - const apiBase = apiUrl.replace(/\/+$/, ''); + const client = createBridgeGitClient(apiUrl, authHeaders); const deadlineAt = Date.now() + BRIDGE_GIT_GENERATION_TIMEOUT_MS; const remainingMs = () => Math.max(1_000, deadlineAt - Date.now()); let sessionId: string | null = null; try { - const sessionUrl = new URL(`${apiBase}/session`); - if (directory) { - sessionUrl.searchParams.set('directory', directory); - } - - const createResponse = await fetch(sessionUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - body: JSON.stringify({ title: 'Git Generation' }), - signal: AbortSignal.timeout(remainingMs()), - }); - - if (!createResponse.ok) { - throw new Error('Failed to create OpenCode session'); - } - - const session = await createResponse.json().catch(() => null) as unknown; + const session = unwrapBridgeSdkData( + await client.session.create({ + ...(directory ? { directory } : {}), + title: 'Git Generation', + }, { signal: AbortSignal.timeout(remainingMs()) }), + 'session.create' + ); const sessionObj = session && typeof session === 'object' ? session as Record : null; const createdSessionId = sessionObj && typeof sessionObj.id === 'string' ? sessionObj.id : ''; if (!createdSessionId) { @@ -187,54 +207,33 @@ const generateBridgeTextWithSessionFlow = async ({ } sessionId = createdSessionId; - const promptUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/prompt_async`); - if (directory) { - promptUrl.searchParams.set('directory', directory); - } - - const promptResponse = await fetch(promptUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - body: JSON.stringify({ + assertBridgeSdkSuccess( + await client.session.promptAsync({ + sessionID: sessionId, + ...(directory ? { directory } : {}), model: { providerID, modelID, }, parts: [{ type: 'text', text: prompt }], - }), - signal: AbortSignal.timeout(remainingMs()), - }); - - if (!promptResponse.ok) { - throw new Error('Failed to send prompt'); - } - - const messagesUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/message`); - if (directory) { - messagesUrl.searchParams.set('directory', directory); - } - messagesUrl.searchParams.set('limit', '10'); + }, { signal: AbortSignal.timeout(remainingMs()) }), + 'session.promptAsync' + ); while (Date.now() < deadlineAt) { await sleep(BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS); - const messagesResponse = await fetch(messagesUrl.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...headers, - }, - signal: AbortSignal.timeout(remainingMs()), - }); + const messagesResponse = await client.session.messages({ + sessionID: sessionId, + ...(directory ? { directory } : {}), + limit: 10, + }, { signal: AbortSignal.timeout(remainingMs()) }); - if (!messagesResponse.ok) { + if (messagesResponse.error) { continue; } - const messages = await messagesResponse.json().catch(() => null) as unknown; + const messages = messagesResponse.data; if (!Array.isArray(messages)) { continue; } @@ -259,13 +258,8 @@ const generateBridgeTextWithSessionFlow = async ({ throw new Error('Timeout waiting for generation to complete'); } finally { if (sessionId) { - const deleteUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}`); try { - await fetch(deleteUrl.toString(), { - method: 'DELETE', - headers, - signal: AbortSignal.timeout(5_000), - }); + await client.session.delete({ sessionID: sessionId }, { signal: AbortSignal.timeout(5_000) }); } catch { // ignore cleanup failures } diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.ts b/packages/vscode/src/bridge-localfs-proxy-runtime.ts index 6f86ba42..b7946444 100644 --- a/packages/vscode/src/bridge-localfs-proxy-runtime.ts +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.ts @@ -40,6 +40,13 @@ const buildProxyJsonError = (status: number, error: string): ApiProxyResponsePay bodyBase64: base64EncodeUtf8(JSON.stringify({ error })), }); +const normalizeFsProxyPath = (pathname: string): '/api/fs/stat' | '/api/fs/read' | '/api/fs/raw' | null => { + if (pathname === '/api/fs/stat' || pathname === '/fs/stat') return '/api/fs/stat'; + if (pathname === '/api/fs/read' || pathname === '/fs/read') return '/api/fs/read'; + if (pathname === '/api/fs/raw' || pathname === '/fs/raw') return '/api/fs/raw'; + return null; +}; + export const tryHandleLocalFsProxy = async (method: string, requestPath: string): Promise => { let parsed: URL; try { @@ -48,7 +55,8 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) return buildProxyJsonError(400, 'Invalid request path'); } - if (parsed.pathname !== '/api/fs/stat' && parsed.pathname !== '/api/fs/read' && parsed.pathname !== '/api/fs/raw') { + const fsProxyPath = normalizeFsProxyPath(parsed.pathname); + if (!fsProxyPath) { return null; } @@ -68,7 +76,7 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) return buildProxyJsonError(400, 'Specified path is not a file'); } - if (parsed.pathname === '/api/fs/stat') { + if (fsProxyPath === '/api/fs/stat') { return { status: 200, headers: { @@ -84,7 +92,7 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) }; } - if (parsed.pathname === '/api/fs/read') { + if (fsProxyPath === '/api/fs/read') { const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); return { status: 200, @@ -110,7 +118,7 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) if (err?.code === 'ENOENT') { return buildProxyJsonError(404, 'File not found'); } - if (parsed.pathname === '/api/fs/stat') { + if (fsProxyPath === '/api/fs/stat') { return buildProxyJsonError(500, 'Unable to stat file'); } return buildProxyJsonError(500, 'Unable to read file'); diff --git a/packages/vscode/src/bridge-proxy-runtime.test.ts b/packages/vscode/src/bridge-proxy-runtime.test.ts new file mode 100644 index 00000000..17c832bb --- /dev/null +++ b/packages/vscode/src/bridge-proxy-runtime.test.ts @@ -0,0 +1,59 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { BridgeContext } from './bridge'; +import { handleProxyBridgeMessage } from './bridge-proxy-runtime'; + +const deps = { + tryHandleLocalFsProxy: async () => null, + buildUnavailableApiResponse: () => ({ status: 503, headers: {}, bodyText: '' }), + sanitizeForwardHeaders: (input: Record | undefined) => input ?? {}, + collectHeaders: (headers: Headers) => { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; + }, + base64EncodeUtf8: (text: string) => Buffer.from(text, 'utf8').toString('base64'), +}; + +const ctx = { + manager: { + getApiUrl: () => 'http://127.0.0.1:3902', + getOpenCodeAuthHeaders: () => ({}), + }, +} as unknown as BridgeContext; + +describe('VS Code API proxy aborts', () => { + test('aborts non-SSE api:proxy fetches by bridge request id', async () => { + const originalFetch = globalThis.fetch; + let capturedSignal: AbortSignal | undefined; + + try { + globalThis.fetch = (async (_input: Parameters[0], init?: RequestInit) => { + capturedSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + capturedSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); + }); + }) as typeof fetch; + + const pending = handleProxyBridgeMessage( + { id: 'req_1', type: 'api:proxy', payload: { method: 'POST', path: '/session/abc/prompt_async', bodyBase64: Buffer.from('{}').toString('base64') } }, + ctx, + deps, + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(capturedSignal?.aborted, false); + + await handleProxyBridgeMessage({ id: 'abort_req_1', type: 'api:proxy:abort', payload: { requestID: 'req_1' } }, ctx, deps); + assert.equal(capturedSignal?.aborted, true); + + const response = await pending; + assert.equal(response?.success, true); + assert.equal((response?.data as { status?: number }).status, 502); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/vscode/src/bridge-proxy-runtime.ts b/packages/vscode/src/bridge-proxy-runtime.ts index f40630ea..34a486a1 100644 --- a/packages/vscode/src/bridge-proxy-runtime.ts +++ b/packages/vscode/src/bridge-proxy-runtime.ts @@ -20,6 +20,10 @@ type ApiSessionMessageRequestPayload = { bodyText?: string; }; +type ApiProxyAbortPayload = { + requestID?: string; +}; + type ApiProxyResponsePayload = { status: number; headers: Record; @@ -59,6 +63,8 @@ type ProxyRuntimeDeps = { base64EncodeUtf8: (text: string) => string; }; +const proxyAbortControllers = new Map(); + export async function handleProxyBridgeMessage( message: BridgeMessageInput, ctx: BridgeContext | undefined, @@ -67,6 +73,15 @@ export async function handleProxyBridgeMessage( const { id, type, payload } = message; switch (type) { + case 'api:proxy:abort': { + const { requestID } = (payload || {}) as ApiProxyAbortPayload; + if (typeof requestID === 'string' && requestID.length > 0) { + proxyAbortControllers.get(requestID)?.abort(); + proxyAbortControllers.delete(requestID); + } + return { id, type, success: true, data: { aborted: true } }; + } + case 'api:proxy': { const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload; const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; @@ -104,6 +119,9 @@ export async function handleProxyBridgeMessage( ...ctx?.manager?.getOpenCodeAuthHeaders(), }; + const abortController = new AbortController(); + proxyAbortControllers.set(id, abortController); + try { const response = await fetch(targetUrl, { method: normalizedMethod, @@ -112,6 +130,7 @@ export async function handleProxyBridgeMessage( typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD' ? Buffer.from(bodyBase64, 'base64') : undefined, + signal: abortController.signal, }); const responseHeaders = collectProxyResponseHeaders(response.headers, deps); @@ -144,6 +163,8 @@ export async function handleProxyBridgeMessage( bodyText: body, }; return { id, type, success: true, data }; + } finally { + proxyAbortControllers.delete(id); } } @@ -178,13 +199,18 @@ export async function handleProxyBridgeMessage( ...deps.sanitizeForwardHeaders(headers), ...ctx?.manager?.getOpenCodeAuthHeaders(), }; + const timeoutSignal = AbortSignal.timeout(45000); + const abortController = new AbortController(); + proxyAbortControllers.set(id, abortController); + const onTimeout = () => abortController.abort(); + timeoutSignal.addEventListener('abort', onTimeout, { once: true }); try { const response = await fetch(targetUrl, { method: 'POST', headers: requestHeaders, body: typeof bodyText === 'string' ? bodyText : '', - signal: AbortSignal.timeout(45000), + signal: abortController.signal, }); const responseHeaders = collectProxyResponseHeaders(response.headers, deps); @@ -221,6 +247,9 @@ export async function handleProxyBridgeMessage( bodyText: body, }; return { id, type, success: true, data }; + } finally { + timeoutSignal.removeEventListener('abort', onTimeout); + proxyAbortControllers.delete(id); } } diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 8a2853eb..1e59ca81 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -7,11 +7,17 @@ import { parse as parseJsonc } from 'jsonc-parser'; const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents'); const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands'); +const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet'); +const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets'); const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json'); const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null; const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i; +const SNIPPET_EXTENSION = '.md'; +const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i; +const HASHTAG_PATTERN = /#([a-z0-9_-]+)/gi; +const MAX_SNIPPET_EXPANSION_COUNT = 15; // Scope types (shared by agents and commands) export const AGENT_SCOPE = { @@ -27,6 +33,46 @@ export const COMMAND_SCOPE = { export type AgentScope = typeof AGENT_SCOPE[keyof typeof AGENT_SCOPE]; export type CommandScope = typeof COMMAND_SCOPE[keyof typeof COMMAND_SCOPE]; +export type SnippetScope = 'global' | 'project'; + +export type Snippet = { + name: string; + content: string; + aliases: string[]; + description?: string; + filePath: string; + source: SnippetScope; +}; + +export type PluginScope = 'user' | 'project'; +export type PluginParsedKind = 'npm' | 'path'; + +export type PluginEntry = { + id: string; + spec: string; + options?: Record; + scope: PluginScope; + kind: 'config'; + parsedKind: PluginParsedKind; +}; + +export type PluginFile = { + id: string; + fileName: string; + scope: PluginScope; + kind: 'file'; +}; + +export type PluginRegistryResult = + | { kind: 'npm-ok'; spec: string; name: string; currentVersion: string | null; latestVersion: string | null; versions: string[]; hasUpdate: boolean } + | { kind: 'npm-missing-version'; spec: string; name: string; currentVersion: string; latestVersion: string | null; versions: string[] } + | { kind: 'npm-missing-package'; spec: string; name: string; error: string } + | { kind: 'npm-malformed'; spec: string; error: string } + | { kind: 'npm-network'; spec: string; error: string } + | { kind: 'path-ok'; spec: string; absolutePath: string } + | { kind: 'path-missing'; spec: string; absolutePath: string } + | { kind: 'path-unreadable'; spec: string; absolutePath: string }; + export type ConfigSources = { md: { exists: boolean; path: string | null; fields: string[]; scope?: AgentScope | CommandScope | null }; json: { exists: boolean; path: string; fields: string[]; scope?: AgentScope | CommandScope | null }; @@ -263,6 +309,174 @@ const getCommandWritePath = (commandName: string, workingDirectory?: string, req }; }; +// ============== SNIPPET HELPERS ============== + +const getProjectSnippetDirs = (workingDirectory?: string): Array<{ dir: string; source: SnippetScope }> => { + if (!workingDirectory) return []; + return [ + { dir: path.join(workingDirectory, '.opencode', 'snippets'), source: 'project' }, + { dir: path.join(workingDirectory, '.opencode', 'snippet'), source: 'project' }, + ]; +}; + +const getGlobalSnippetDirs = (): Array<{ dir: string; source: SnippetScope }> => [ + { dir: GLOBAL_SNIPPET_DIR_ALT, source: 'global' }, + { dir: GLOBAL_SNIPPET_DIR, source: 'global' }, +]; + +const assertValidSnippetName = (name: string): void => { + if (typeof name !== 'string' || !SNIPPET_NAME_PATTERN.test(name)) { + throw new Error('Snippet name must use letters, numbers, dashes, or underscores'); + } +}; + +const normalizeSnippetAliases = (frontmatter: Record): string[] => { + const raw = frontmatter.aliases ?? frontmatter.alias; + if (!raw) return []; + const aliases = Array.isArray(raw) ? raw : [raw]; + return aliases.map((alias) => String(alias).trim()).filter(Boolean); +}; + +const loadSnippetFile = (dir: string, filename: string, source: SnippetScope): Snippet | null => { + const name = path.basename(filename, SNIPPET_EXTENSION); + if (!SNIPPET_NAME_PATTERN.test(name)) return null; + const filePath = path.join(dir, filename); + const { frontmatter, body } = parseMdFile(filePath); + return { + name, + content: body, + aliases: normalizeSnippetAliases(frontmatter), + description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined, + filePath, + source, + }; +}; + +const registerSnippet = (registry: Map, snippet: Snippet): void => { + const key = snippet.name.toLowerCase(); + const existing = registry.get(key); + if (existing) { + for (const alias of existing.aliases) registry.delete(alias.toLowerCase()); + } + registry.set(key, snippet); + for (const alias of snippet.aliases) { + if (SNIPPET_NAME_PATTERN.test(alias)) registry.set(alias.toLowerCase(), snippet); + } +}; + +const loadSnippetRegistry = (workingDirectory?: string): Map => { + const registry = new Map(); + for (const { dir, source } of [...getGlobalSnippetDirs(), ...getProjectSnippetDirs(workingDirectory)]) { + if (!fs.existsSync(dir)) continue; + for (const filename of fs.readdirSync(dir)) { + if (!filename.endsWith(SNIPPET_EXTENSION)) continue; + try { + const snippet = loadSnippetFile(dir, filename, source); + if (snippet) registerSnippet(registry, snippet); + } catch (error) { + console.warn(`[OpenChamber][VSCode] Failed to load snippet ${path.join(dir, filename)}:`, error); + } + } + } + return registry; +}; + +const listUniqueSnippets = (registry: Map): Snippet[] => { + const seen = new Set(); + const snippets: Snippet[] = []; + for (const snippet of registry.values()) { + const key = `${snippet.source}:${snippet.filePath}`; + if (seen.has(key)) continue; + seen.add(key); + snippets.push(snippet); + } + return snippets.sort((a, b) => a.name.localeCompare(b.name)); +}; + +const getWritableSnippetDir = (scope: SnippetScope, workingDirectory?: string): string => { + if (scope === 'project') { + if (!workingDirectory) throw new Error('Project directory is required for project snippets'); + const preferred = path.join(workingDirectory, '.opencode', 'snippet'); + const alternate = path.join(workingDirectory, '.opencode', 'snippets'); + return fs.existsSync(alternate) && !fs.existsSync(preferred) ? alternate : preferred; + } + return fs.existsSync(GLOBAL_SNIPPET_DIR_ALT) && !fs.existsSync(GLOBAL_SNIPPET_DIR) + ? GLOBAL_SNIPPET_DIR_ALT + : GLOBAL_SNIPPET_DIR; +}; + +const findSnippetByName = (name: string, workingDirectory?: string): Snippet | null => { + assertValidSnippetName(name); + return loadSnippetRegistry(workingDirectory).get(name.toLowerCase()) ?? null; +}; + +const writeSnippetFile = (filePath: string, config: Record): void => { + const aliases = Array.isArray(config.aliases) + ? config.aliases.map((alias) => String(alias).trim()).filter(Boolean) + : []; + const frontmatter: Record = {}; + if (aliases.length > 0) frontmatter.aliases = aliases; + if (typeof config.description === 'string' && config.description.trim()) { + frontmatter.description = config.description.trim(); + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + writeMdFile(filePath, frontmatter, typeof config.content === 'string' ? config.content : ''); +}; + +const parseSnippetBlocks = (content: string): { inline: string; prepend: string[]; append: string[] } => { + const blocks = { prepend: [] as string[], append: [] as string[] }; + let inline = content; + for (const type of ['prepend', 'append'] as const) { + const regex = new RegExp(`<${type}>([\\s\\S]*?)(?:<\\/${type}>|$)`, 'gi'); + inline = inline.replace(regex, (_match, value: string) => { + const normalized = String(value).trim(); + if (normalized) blocks[type].push(normalized); + return ''; + }); + } + inline = inline.replace(/[\s\S]*?(?:<\/inject>|$)/gi, '').trim(); + return { inline, prepend: blocks.prepend, append: blocks.append }; +}; + +const expandSnippetText = ( + text: string, + registry: Map, + expansionCounts: Map, + collector: { prepend: string[]; append: string[] }, +): string => { + let expanded = text; + let changed = true; + + while (changed) { + const previous = expanded; + let loopDetected = false; + HASHTAG_PATTERN.lastIndex = 0; + + expanded = expanded.replace(HASHTAG_PATTERN, (match, name: string, offset: number, input: string) => { + if (name.toLowerCase() === 'skill' && input[offset + match.length] === '(') return match; + const snippet = registry.get(name.toLowerCase()); + if (!snippet) return match; + + const key = snippet.name.toLowerCase(); + const count = (expansionCounts.get(key) || 0) + 1; + if (count > MAX_SNIPPET_EXPANSION_COUNT) { + loopDetected = true; + return match; + } + expansionCounts.set(key, count); + + const parsed = parseSnippetBlocks(snippet.content); + for (const block of parsed.prepend) collector.prepend.push(expandSnippetText(block, registry, expansionCounts, collector)); + for (const block of parsed.append) collector.append.push(expandSnippetText(block, registry, expansionCounts, collector)); + return expandSnippetText(parsed.inline, registry, expansionCounts, collector); + }); + + changed = expanded !== previous && !loopDetected; + } + + return expanded; +}; + const isPromptFileReference = (value: unknown): value is string => { return typeof value === 'string' && PROMPT_FILE_PATTERN.test(value.trim()); }; @@ -492,6 +706,494 @@ const writeConfig = (config: Record, filePath: string = CONFIG_ fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8'); }; +const codedError = (message: string, code: string): Error & { code: string } => { + const error = new Error(message) as Error & { code: string }; + error.code = code; + return error; +}; + +const validatePluginScope = (scope: unknown): PluginScope => { + if (scope === 'user' || scope === 'project') return scope; + throw codedError('Plugin scope must be user or project', 'INVALID_SCOPE'); +}; + +const validatePluginSpec = (spec: unknown): string => { + if (typeof spec !== 'string' || spec.trim().length === 0) { + throw codedError('Plugin spec must be a non-empty string', 'INVALID_SPEC'); + } + if (spec.includes('\0')) { + throw codedError('Plugin spec cannot contain null bytes', 'INVALID_SPEC'); + } + return spec.trim(); +}; + +const PLUGIN_FILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/; + +const validatePluginFileName = (fileName: unknown): string => { + if (typeof fileName !== 'string' || fileName.trim().length === 0) { + throw codedError('Plugin file name is required', 'INVALID_FILENAME'); + } + const normalized = fileName.trim(); + if ( + normalized.includes('/') || + normalized.includes('\\') || + normalized.includes('..') || + !PLUGIN_FILE_NAME_PATTERN.test(normalized) + ) { + throw codedError('Plugin file name must match /^[a-z0-9][a-z0-9-_.]*\\.(js|ts|mjs|cjs)$/ and cannot contain path traversal', 'INVALID_FILENAME'); + } + return normalized; +}; + +const encodePluginId = (prefix: 'config' | 'file', value: string): string => + Buffer.from(`${prefix}:${value}`, 'utf8').toString('base64url'); + +const decodePluginId = (id: string): { prefix: string; value: string } => { + try { + const decoded = Buffer.from(id, 'base64url').toString('utf8'); + const separator = decoded.indexOf(':'); + if (separator <= 0) throw new Error('invalid plugin id'); + return { prefix: decoded.slice(0, separator), value: decoded.slice(separator + 1) }; + } catch { + throw codedError('Invalid plugin id', 'INVALID_SPEC'); + } +}; + +const parsePluginIdValue = (value: string): { scope: PluginScope; rest: string } => { + const separator = value.indexOf(':'); + if (separator <= 0) { + throw codedError('Plugin id value must include scope', 'INVALID_SPEC'); + } + return { + scope: validatePluginScope(value.slice(0, separator)), + rest: value.slice(separator + 1), + }; +}; + +const parsePluginRaw = (raw: unknown): { spec: string; options?: Record } => { + if (typeof raw === 'string') { + return { spec: validatePluginSpec(raw) }; + } + if (Array.isArray(raw) && typeof raw[0] === 'string' && isPlainObject(raw[1])) { + return { spec: validatePluginSpec(raw[0]), options: { ...raw[1] } }; + } + throw codedError('Plugin spec must be a string or [string, object]', 'INVALID_SPEC'); +}; + +const serializePluginEntry = (entry: { spec?: unknown; options?: unknown }): string | [string, Record] => { + const spec = validatePluginSpec(entry.spec); + if (isPlainObject(entry.options) && Object.keys(entry.options).length > 0) { + return [spec, { ...entry.options }]; + } + return spec; +}; + +const isPluginPathSpec = (spec: string): boolean => + spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('~') || path.win32.isAbsolute(spec); + +const parsePluginPathSpec = (spec: string, workingDirectory?: string | null): { absolutePath: string } => { + if (spec === '~') return { absolutePath: path.resolve(os.homedir()) }; + if (spec.startsWith('~/')) return { absolutePath: path.resolve(os.homedir(), spec.slice(2)) }; + if (spec.startsWith('./') || spec.startsWith('../')) { + return { absolutePath: path.resolve(workingDirectory || os.homedir(), spec) }; + } + if (path.win32.isAbsolute(spec)) return { absolutePath: spec }; + return { absolutePath: path.resolve(spec) }; +}; + +const parsePluginNpmSpec = (spec: string): { name: string; version: string | null } | { malformed: true } => { + if (spec.startsWith('@')) { + const slashIdx = spec.indexOf('/'); + if (slashIdx < 2) return { malformed: true }; + const afterSlash = spec.slice(slashIdx + 1); + if (!afterSlash) return { malformed: true }; + const atIdx = afterSlash.indexOf('@'); + if (atIdx === -1) return { name: spec, version: null }; + const version = afterSlash.slice(atIdx + 1); + if (!version) return { malformed: true }; + return { name: spec.slice(0, slashIdx + 1 + atIdx), version }; + } + if (!spec) return { malformed: true }; + const atIdx = spec.indexOf('@'); + if (atIdx === -1) return { name: spec, version: null }; + if (atIdx === 0) return { malformed: true }; + const version = spec.slice(atIdx + 1); + if (!version) return { malformed: true }; + return { name: spec.slice(0, atIdx), version }; +}; + +const isExactPluginSemver = (version: string): boolean => /^\d+\.\d+\.\d+([-+][\w.-]+)?$/.test(version); + +const getActiveCustomConfigPath = (): string | null => + process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null; + +const getActiveOpencodeConfigDir = (): string => { + const customConfigPath = getActiveCustomConfigPath(); + return customConfigPath ? path.dirname(customConfigPath) : OPENCODE_CONFIG_DIR; +}; + +const getActiveUserConfigPaths = (): string[] => { + const configDir = getActiveOpencodeConfigDir(); + return [ + path.join(configDir, 'config.json'), + path.join(configDir, 'opencode.json'), + path.join(configDir, 'opencode.jsonc'), + ]; +}; + +const getActivePrimaryUserConfigPath = (): string => { + const [defaultPath, ...fallbackPaths] = getActiveUserConfigPaths(); + for (const userPath of [defaultPath, ...fallbackPaths]) { + if (fs.existsSync(userPath)) { + return userPath; + } + } + return defaultPath; +}; + +const ensureProjectPluginConfigPath = (workingDirectory?: string | null): string => { + if (!workingDirectory) throw codedError('Project plugin scope requires working directory', 'INVALID_SCOPE'); + return path.join(workingDirectory, '.opencode', 'opencode.json'); +}; + +const getPluginConfigSources = (workingDirectory?: string | null): Array<{ scope: PluginScope; path: string; config: Record }> => { + const customPath = getActiveCustomConfigPath(); + const userPath = getActivePrimaryUserConfigPath(); + const projectPath = getProjectConfigPath(workingDirectory || undefined); + return [ + customPath + ? { scope: 'user', path: customPath, config: readConfigFile(customPath) } + : { scope: 'user', path: userPath, config: readConfigFile(userPath) }, + ...(projectPath + ? [{ scope: 'project' as const, path: projectPath, config: readConfigFile(projectPath) }] + : []), + ]; +}; + +const readPluginArray = (config: Record): unknown[] => Array.isArray(config.plugin) ? config.plugin : []; + +const writePluginArray = (config: Record, plugin: unknown[]): Record => { + const next = { ...config }; + if (plugin.length > 0) { + next.plugin = plugin; + } else { + delete next.plugin; + } + return next; +}; + +const hasPluginSpec = (plugin: unknown[], spec: string): boolean => plugin.some((raw) => { + try { + return parsePluginRaw(raw).spec === spec; + } catch { + return false; + } +}); + +const getPluginTarget = (id: string, workingDirectory?: string | null): null | { + scope: PluginScope; + path: string; + config: Record; + plugin: unknown[]; + index: number; + spec: string; +} => { + const decoded = decodePluginId(id); + if (decoded.prefix !== 'config') { + throw codedError('Plugin entry id must use config prefix', 'INVALID_SPEC'); + } + const { scope, rest: spec } = parsePluginIdValue(decoded.value); + const source = getPluginConfigSources(workingDirectory).find((candidate) => candidate.scope === scope); + if (!source) return null; + const plugin = readPluginArray(source.config); + const index = plugin.findIndex((raw) => { + try { + return parsePluginRaw(raw).spec === spec; + } catch { + return false; + } + }); + if (index < 0) return null; + return { scope, path: source.path, config: source.config, plugin: [...plugin], index, spec }; +}; + +export const listPluginEntries = (workingDirectory?: string): PluginEntry[] => { + const entries: PluginEntry[] = []; + for (const source of getPluginConfigSources(workingDirectory)) { + for (const raw of readPluginArray(source.config)) { + try { + const parsed = parsePluginRaw(raw); + entries.push({ + id: encodePluginId('config', `${source.scope}:${parsed.spec}`), + spec: parsed.spec, + ...(parsed.options ? { options: parsed.options } : {}), + scope: source.scope, + kind: 'config', + parsedKind: isPluginPathSpec(parsed.spec) ? 'path' : 'npm', + }); + } catch { + // Ignore malformed persisted plugin entries so the settings page remains usable. + } + } + } + return entries; +}; + +export const getPluginEntry = (id: string, workingDirectory?: string): PluginEntry | null => + listPluginEntries(workingDirectory).find((entry) => entry.id === id) || null; + +export const createPluginEntry = (entry: { spec?: unknown; options?: unknown; scope?: unknown }, workingDirectory?: string): void => { + const spec = validatePluginSpec(entry.spec); + const scope = validatePluginScope(entry.scope || 'user'); + const sources = getPluginConfigSources(workingDirectory); + if (sources.some((source) => source.scope === scope && hasPluginSpec(readPluginArray(source.config), spec))) { + throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS'); + } + const userSource = sources.find((source) => source.scope === 'user'); + const targetPath = scope === 'project' + ? ensureProjectPluginConfigPath(workingDirectory) + : userSource?.path ?? getActivePrimaryUserConfigPath(); + const config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {}; + const plugin = readPluginArray(config); + writeConfig(writePluginArray(config, [...plugin, serializePluginEntry({ spec, options: entry.options })]), targetPath); +}; + +export const updatePluginEntry = (id: string, updates: { spec?: unknown; options?: unknown }, workingDirectory?: string): void => { + const target = getPluginTarget(id, workingDirectory); + if (!target) throw codedError('Plugin entry not found', 'NOT_FOUND'); + const existing = parsePluginRaw(target.plugin[target.index]); + const nextSpec = updates.spec === undefined ? existing.spec : validatePluginSpec(updates.spec); + const nextOptions = updates.options === undefined ? existing.options : updates.options; + target.plugin[target.index] = serializePluginEntry({ spec: nextSpec, options: nextOptions }); + writeConfig(writePluginArray(target.config, target.plugin), target.path); +}; + +export const deletePluginEntry = (id: string, workingDirectory?: string): void => { + const target = getPluginTarget(id, workingDirectory); + if (!target) throw codedError('Plugin entry not found', 'NOT_FOUND'); + target.plugin.splice(target.index, 1); + writeConfig(writePluginArray(target.config, target.plugin), target.path); +}; + +const getPluginDir = (scope: PluginScope, workingDirectory?: string | null): string => { + if (scope === 'project') { + if (!workingDirectory) throw codedError('Project plugin scope requires working directory', 'INVALID_SCOPE'); + return path.join(workingDirectory, '.opencode', 'plugins'); + } + return path.join(getActiveOpencodeConfigDir(), 'plugins'); +}; + +const getPluginFileTarget = (id: string, workingDirectory?: string): { scope: PluginScope; fileName: string; filePath: string } => { + const decoded = decodePluginId(id); + if (decoded.prefix !== 'file') { + throw codedError('Plugin file id must use file prefix', 'INVALID_FILENAME'); + } + const { scope, rest } = parsePluginIdValue(decoded.value); + const fileName = validatePluginFileName(rest); + return { scope, fileName, filePath: path.join(getPluginDir(scope, workingDirectory), fileName) }; +}; + +export const listPluginDirFiles = (workingDirectory?: string): PluginFile[] => { + const files: PluginFile[] = []; + for (const scope of ['user', 'project'] as const) { + let dir: string; + try { + dir = getPluginDir(scope, workingDirectory); + } catch { + continue; + } + if (!fs.existsSync(dir)) continue; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + try { + const fileName = validatePluginFileName(entry.name); + files.push({ + id: encodePluginId('file', `${scope}:${fileName}`), + fileName, + scope, + kind: 'file', + }); + } catch { + // Ignore unsupported files in the plugins directory. + } + } + } + return files.sort((a, b) => `${a.scope}:${a.fileName}`.localeCompare(`${b.scope}:${b.fileName}`)); +}; + +export const readPluginDirFile = (id: string, workingDirectory?: string): { fileName: string; scope: PluginScope; content: string } | null => { + const target = getPluginFileTarget(id, workingDirectory); + if (!fs.existsSync(target.filePath)) return null; + return { + fileName: target.fileName, + scope: target.scope, + content: fs.readFileSync(target.filePath, 'utf8'), + }; +}; + +export const writePluginDirFile = ( + file: { fileName?: unknown; content?: unknown; scope?: unknown }, + workingDirectory?: string, + opts: { overwrite?: boolean } = {}, +): void => { + const scope = validatePluginScope(file.scope || 'user'); + const fileName = validatePluginFileName(file.fileName); + const filePath = path.join(getPluginDir(scope, workingDirectory), fileName); + if (!opts.overwrite && fs.existsSync(filePath)) { + throw codedError(`Plugin file "${fileName}" already exists`, 'FILE_EXISTS'); + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, typeof file.content === 'string' ? file.content : '', 'utf8'); +}; + +export const deletePluginDirFile = (id: string, workingDirectory?: string): void => { + const target = getPluginFileTarget(id, workingDirectory); + if (!fs.existsSync(target.filePath)) { + throw codedError(`Plugin file "${target.fileName}" not found`, 'NOT_FOUND'); + } + fs.rmSync(target.filePath, { force: true }); +}; + +type NpmLookupResult = + | { ok: true; latest: string | null; versions: string[] } + | { ok: false; status: number | 'network'; error: string }; + +const npmInfoCache = new Map(); +const npmInfoInFlight = new Map>(); +const NPM_CACHE_TTL_MS = 3_600_000; + +const lookupNpmPackage = async (name: string): Promise => { + try { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name).replace(/^%40/, '@')}`, { + headers: { Accept: 'application/json', 'User-Agent': 'openchamber-vscode/dev' }, + signal: AbortSignal.timeout(5000), + }); + if (response.ok) { + const data = await response.json() as { versions?: unknown; 'dist-tags'?: { latest?: unknown } }; + return { + ok: true, + latest: typeof data['dist-tags']?.latest === 'string' ? data['dist-tags'].latest : null, + versions: isPlainObject(data.versions) ? Object.keys(data.versions) : [], + }; + } + if (response.status === 404) return { ok: false, status: 404, error: 'Package not found' }; + return { ok: false, status: response.status, error: `Registry returned ${response.status}` }; + } catch (error) { + return { ok: false, status: 'network', error: error instanceof Error ? error.message : String(error) }; + } +}; + +const getNpmInfo = async (name: string, forceRefresh = false): Promise => { + const cached = npmInfoCache.get(name); + if (cached && !forceRefresh && Date.now() - cached.fetchedAt < NPM_CACHE_TTL_MS) { + return cached.payload; + } + const existing = npmInfoInFlight.get(name); + if (existing && !forceRefresh) return existing; + const lookup = lookupNpmPackage(name); + npmInfoInFlight.set(name, lookup); + try { + const result = await lookup; + if (result.ok || result.status === 404) { + npmInfoCache.set(name, { fetchedAt: Date.now(), payload: result }); + } + return result; + } finally { + if (npmInfoInFlight.get(name) === lookup) { + npmInfoInFlight.delete(name); + } + } +}; + +export const queryPluginRegistry = async ( + specs: string[], + opts: { refresh?: boolean; workingDirectory?: string } = {}, +): Promise<{ results: PluginRegistryResult[] }> => { + const uniqueSpecs = Array.from(new Set(specs.filter((spec) => spec.length > 0))); + if (uniqueSpecs.length > 100) { + throw codedError('too many specs', 'INVALID_SPEC'); + } + + const npmJobs = new Map(); + const malformedSpecs = new Set(); + for (const spec of uniqueSpecs) { + if (isPluginPathSpec(spec)) continue; + const parsed = parsePluginNpmSpec(spec); + if ('malformed' in parsed) { + malformedSpecs.add(spec); + continue; + } + npmJobs.set(parsed.name, [...(npmJobs.get(parsed.name) || []), spec]); + } + + const npmInfoByName = new Map(); + await Promise.all(Array.from(npmJobs.keys()).map(async (name) => { + npmInfoByName.set(name, await getNpmInfo(name, opts.refresh === true)); + })); + + const results: PluginRegistryResult[] = []; + for (const spec of uniqueSpecs) { + if (malformedSpecs.has(spec)) { + results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' }); + continue; + } + + if (isPluginPathSpec(spec)) { + const { absolutePath } = parsePluginPathSpec(spec, opts.workingDirectory || os.homedir()); + try { + fs.statSync(absolutePath); + } catch { + results.push({ kind: 'path-missing', spec, absolutePath }); + continue; + } + try { + fs.accessSync(absolutePath, fs.constants.R_OK); + results.push({ kind: 'path-ok', spec, absolutePath }); + } catch { + results.push({ kind: 'path-unreadable', spec, absolutePath }); + } + continue; + } + + const parsed = parsePluginNpmSpec(spec); + if ('malformed' in parsed) { + results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' }); + continue; + } + const info = npmInfoByName.get(parsed.name); + if (!info?.ok) { + if (info?.status === 404) { + results.push({ kind: 'npm-missing-package', spec, name: parsed.name, error: info.error }); + } else { + results.push({ kind: 'npm-network', spec, error: info?.status === 'network' ? info.error : `Registry returned ${info?.status ?? 'unknown'}` }); + } + continue; + } + const currentVersion = parsed.version; + if (currentVersion !== null && isExactPluginSemver(currentVersion) && !info.versions.includes(currentVersion)) { + results.push({ + kind: 'npm-missing-version', + spec, + name: parsed.name, + currentVersion, + latestVersion: info.latest, + versions: info.versions, + }); + continue; + } + results.push({ + kind: 'npm-ok', + spec, + name: parsed.name, + currentVersion, + latestVersion: info.latest, + versions: info.versions, + hasUpdate: currentVersion !== null && isExactPluginSemver(currentVersion) && currentVersion !== info.latest, + }); + } + return { results }; +}; + export type McpLocalConfig = { type: 'local'; command?: string[]; @@ -1284,6 +1986,48 @@ export const deleteCommand = (commandName: string, workingDirectory?: string) => } }; +export const listSnippets = (workingDirectory?: string): Snippet[] => { + return listUniqueSnippets(loadSnippetRegistry(workingDirectory)); +}; + +export const getSnippet = (name: string, workingDirectory?: string): Snippet | null => { + return findSnippetByName(name, workingDirectory); +}; + +export const createSnippet = ( + name: string, + config: Record, + workingDirectory?: string, + scope: SnippetScope = 'global', +): Snippet | null => { + assertValidSnippetName(name); + const dir = getWritableSnippetDir(scope, workingDirectory); + const filePath = path.join(dir, `${name}${SNIPPET_EXTENSION}`); + if (fs.existsSync(filePath)) throw new Error(`Snippet "${name}" already exists`); + writeSnippetFile(filePath, config || {}); + return getSnippet(name, workingDirectory); +}; + +export const updateSnippet = (name: string, updates: Record, workingDirectory?: string): Snippet | null => { + const existing = findSnippetByName(name, workingDirectory); + if (!existing) throw new Error(`Snippet "${name}" not found`); + writeSnippetFile(existing.filePath, { ...existing, ...(updates || {}) }); + return getSnippet(name, workingDirectory); +}; + +export const deleteSnippet = (name: string, workingDirectory?: string): void => { + const existing = findSnippetByName(name, workingDirectory); + if (!existing) throw new Error(`Snippet "${name}" not found`); + fs.unlinkSync(existing.filePath); +}; + +export const expandSnippets = (text: string, workingDirectory?: string): string => { + const registry = loadSnippetRegistry(workingDirectory); + const collector = { prepend: [] as string[], append: [] as string[] }; + const expanded = expandSnippetText(text || '', registry, new Map(), collector).trim(); + return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n'); +}; + // ============== SKILL SCOPE HELPERS ============== const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills'); diff --git a/packages/vscode/webview/api/bridge.test.ts b/packages/vscode/webview/api/bridge.test.ts new file mode 100644 index 00000000..a775220c --- /dev/null +++ b/packages/vscode/webview/api/bridge.test.ts @@ -0,0 +1,44 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +describe('VS Code webview bridge requests', () => { + test('rejects immediately when signal is already aborted', async () => { + const originalWindow = globalThis.window; + const originalAcquire = (globalThis as typeof globalThis & { acquireVsCodeApi?: unknown }).acquireVsCodeApi; + const messages: unknown[] = []; + + try { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: new EventTarget(), + }); + Object.defineProperty(globalThis, 'acquireVsCodeApi', { + configurable: true, + value: () => ({ + postMessage: (message: unknown) => messages.push(message), + getState: () => undefined, + setState: () => undefined, + }), + }); + + const { sendBridgeMessageWithOptions } = await import('./bridge'); + const controller = new AbortController(); + controller.abort(); + + const result = await Promise.race([ + sendBridgeMessageWithOptions('api:proxy', undefined, { signal: controller.signal }).then( + () => 'resolved', + (error: unknown) => error, + ), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 20)), + ]); + + assert.ok(result instanceof DOMException); + assert.equal(result.name, 'AbortError'); + assert.equal(messages.length, 0); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'acquireVsCodeApi', { configurable: true, value: originalAcquire }); + } + }); +}); diff --git a/packages/vscode/webview/api/bridge.ts b/packages/vscode/webview/api/bridge.ts index c2cedee1..6e0a6dfd 100644 --- a/packages/vscode/webview/api/bridge.ts +++ b/packages/vscode/webview/api/bridge.ts @@ -40,6 +40,7 @@ const pendingRequests = new Map void; reject: (reason: Error) => void; timeout?: ReturnType; + onAbort?: () => void; }>(); let requestIdCounter = 0; @@ -59,6 +60,9 @@ window.addEventListener('message', (event: MessageEvent) => { if (pending.timeout) { clearTimeout(pending.timeout); } + if (pending.onAbort) { + pending.onAbort(); + } if (response.success) { pending.resolve(response.data); } else { @@ -74,7 +78,7 @@ export function sendBridgeMessage(type: string, payload?: unknown): export function sendBridgeMessageWithOptions( type: string, payload?: unknown, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; signal?: AbortSignal; onAbort?: (id: string) => void } ): Promise { return new Promise((resolve, reject) => { const id = `req_${++requestIdCounter}_${Date.now()}`; @@ -84,10 +88,30 @@ export function sendBridgeMessageWithOptions( resolve: (value: unknown) => void; reject: (reason: Error) => void; timeout?: ReturnType; + onAbort?: () => void; } = { resolve: resolve as (value: unknown) => void, reject, }; + + if (options?.signal) { + const abort = () => { + if (!pendingRequests.has(id)) return; + pendingRequests.delete(id); + if (pending.timeout) { + clearTimeout(pending.timeout); + } + options.onAbort?.(id); + reject(new DOMException('Aborted', 'AbortError')); + }; + if (options.signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + options.signal.addEventListener('abort', abort, { once: true }); + pending.onAbort = () => options.signal?.removeEventListener('abort', abort); + } + pendingRequests.set(id, pending); const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 30000; @@ -95,6 +119,9 @@ export function sendBridgeMessageWithOptions( pending.timeout = setTimeout(() => { if (pendingRequests.has(id)) { pendingRequests.delete(id); + if (pending.onAbort) { + pending.onAbort(); + } reject(new Error(`Request ${type} timed out`)); } }, timeoutMs); @@ -116,19 +143,31 @@ export async function proxyApiRequest(options: { path: string; headers?: Record; bodyBase64?: string; + signal?: AbortSignal; }): Promise { // Do not impose a bridge-level timeout. Let the original fetch's AbortSignal // (or OpenCode server response timing) control the lifecycle. - return sendBridgeMessageWithOptions('api:proxy', options, { timeoutMs: 0 }); + const { signal, ...payload } = options; + return sendBridgeMessageWithOptions('api:proxy', payload, { + timeoutMs: 0, + signal, + onAbort: (requestID) => getVSCodeAPI().postMessage({ id: `abort_${requestID}`, type: 'api:proxy:abort', payload: { requestID } }), + }); } export async function proxySessionMessageRequest(options: { path: string; headers?: Record; bodyText: string; + signal?: AbortSignal; }): Promise { // Keep parity with server-side direct forwarder: let extension host control timeout. - return sendBridgeMessageWithOptions('api:session:message', options, { timeoutMs: 0 }); + const { signal, ...payload } = options; + return sendBridgeMessageWithOptions('api:session:message', payload, { + timeoutMs: 0, + signal, + onAbort: (requestID) => getVSCodeAPI().postMessage({ id: `abort_${requestID}`, type: 'api:proxy:abort', payload: { requestID } }), + }); } export type ProxiedSseStartResponse = { diff --git a/packages/vscode/webview/api/files.ts b/packages/vscode/webview/api/files.ts index f778fd0b..4fecfd49 100644 --- a/packages/vscode/webview/api/files.ts +++ b/packages/vscode/webview/api/files.ts @@ -36,30 +36,30 @@ export const createVSCodeFilesAPI = (): FilesAPI => ({ async search(payload: FileSearchQuery): Promise { const directory = normalizePath(payload.directory); - const params = new URLSearchParams(); - if (directory) { - params.set('directory', directory); - } - params.set('query', payload.query); - params.set('dirs', 'false'); - params.set('type', 'file'); - if (typeof payload.maxResults === 'number' && Number.isFinite(payload.maxResults)) { - params.set('limit', String(payload.maxResults)); - } + const data = await sendBridgeMessage<{ + files?: Array<{ path?: string; relativePath?: string }>; + }>('api:fs:search', { + directory, + query: payload.query, + limit: payload.maxResults, + includeHidden: false, + respectGitignore: true, + }); - const response = await fetch(`/api/find/file?${params.toString()}`); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error((error as { error?: string }).error || 'Failed to search files'); - } + const files = Array.isArray(data?.files) ? data.files : []; - const result = (await response.json()) as string[]; - const files = Array.isArray(result) ? result : []; - - return files.map((relativePath) => ({ - path: normalizePath(`${directory}/${relativePath}`), - preview: [normalizePath(relativePath)], - })); + return files.map((file) => { + const relativePath = typeof file.relativePath === 'string' + ? normalizePath(file.relativePath) + : normalizePath(file.path || ''); + const absolutePath = typeof file.path === 'string' + ? normalizePath(file.path) + : normalizePath(`${directory}/${relativePath}`); + return { + path: absolutePath, + preview: [relativePath || absolutePath], + }; + }); }, async createDirectory(path: string): Promise<{ success: boolean; path: string }> { diff --git a/packages/vscode/webview/api/settings.ts b/packages/vscode/webview/api/settings.ts index 63c4b39d..80491b74 100644 --- a/packages/vscode/webview/api/settings.ts +++ b/packages/vscode/webview/api/settings.ts @@ -1,8 +1,5 @@ import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types'; - -// Use same endpoints as web - fetch interceptor handles URL rewriting -const SETTINGS_ENDPOINT = '/api/config/settings'; -const RELOAD_ENDPOINT = '/api/config/reload'; +import { sendBridgeMessage } from './bridge'; const sanitizePayload = (data: unknown): SettingsPayload => { if (!data || typeof data !== 'object') { @@ -13,12 +10,17 @@ const sanitizePayload = (data: unknown): SettingsPayload => { export const createVSCodeSettingsAPI = (): SettingsAPI => ({ async load(): Promise { - const response = await fetch(SETTINGS_ENDPOINT, { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { + try { + const payload = sanitizePayload(await sendBridgeMessage('api:config/settings:get')); + return { + settings: { + ...payload, + // Override with VS Code settings + lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || payload.lastDirectory || '', + }, + source: 'web', + }; + } catch { // Fallback to VS Code config return { settings: { @@ -28,43 +30,14 @@ export const createVSCodeSettingsAPI = (): SettingsAPI => ({ source: 'web', }; } - - const payload = sanitizePayload(await response.json().catch(() => ({}))); - return { - settings: { - ...payload, - // Override with VS Code settings - lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || payload.lastDirectory || '', - }, - source: 'web', - }; }, async save(changes: Partial): Promise { - const response = await fetch(SETTINGS_ENDPOINT, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(changes), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(error.error || 'Failed to save settings'); - } - - const payload = sanitizePayload(await response.json().catch(() => ({}))); - return payload; + return sanitizePayload(await sendBridgeMessage('api:config/settings:save', changes)); }, async restartOpenCode(): Promise<{ restarted: boolean }> { - const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' }); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(error.error || 'Failed to restart OpenCode'); - } + await sendBridgeMessage('api:config/reload'); return { restarted: true }; }, }); diff --git a/packages/vscode/webview/api/tools.ts b/packages/vscode/webview/api/tools.ts index 530cd3dc..06482171 100644 --- a/packages/vscode/webview/api/tools.ts +++ b/packages/vscode/webview/api/tools.ts @@ -1,16 +1,9 @@ import type { ToolsAPI } from '@openchamber/ui/lib/api/types'; +import { opencodeClient } from '@openchamber/ui/lib/opencode/client'; -// Use same endpoint as web - fetch interceptor handles URL rewriting export const createVSCodeToolsAPI = (): ToolsAPI => ({ async getAvailableTools(): Promise { - const response = await fetch('/api/experimental/tool/ids'); - - if (!response.ok) { - throw new Error(`Tools API returned ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - + const data = await opencodeClient.listToolIds(); if (!Array.isArray(data)) { throw new Error('Tools API returned invalid data format'); } diff --git a/packages/vscode/webview/api/vscode.ts b/packages/vscode/webview/api/vscode.ts index 1bcea35e..e1aaef80 100644 --- a/packages/vscode/webview/api/vscode.ts +++ b/packages/vscode/webview/api/vscode.ts @@ -1,5 +1,5 @@ import type { VSCodeAPI } from '@openchamber/ui/lib/api/types'; -import { executeVSCodeCommand, openVSCodeExternalUrl } from './bridge'; +import { executeVSCodeCommand, openVSCodeExternalUrl, sendBridgeMessage } from './bridge'; export const createVSCodeActionsAPI = (): VSCodeAPI => ({ async executeCommand(command: string, ...args: unknown[]): Promise { @@ -14,4 +14,16 @@ export const createVSCodeActionsAPI = (): VSCodeAPI => ({ async openExternalUrl(url: string): Promise { await openVSCodeExternalUrl(url); }, + + async pickFiles(): Promise { + return sendBridgeMessage('api:files/pick'); + }, + + async saveImage(payload: unknown): Promise { + return sendBridgeMessage('api:files/save-image', payload); + }, + + async saveMarkdown(payload: unknown): Promise { + return sendBridgeMessage('api:files/save-markdown', payload); + }, }); diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 26038643..c3b907de 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1,7 +1,9 @@ import { createVSCodeAPIs } from './api'; import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge'; import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf'; +import { extractBodyBase64, extractBodyText, extractJsonBody, hasInitBody } from './requestBodyTransport'; import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; +import { opencodeClient } from '@openchamber/ui/lib/opencode/client'; import { buildVSCodeThemeFromPalette, readVSCodeThemePalette, @@ -315,6 +317,22 @@ const headersToRecord = (headers: HeadersInit | undefined): Record => { + const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {}; + const headersFromInit = headersToRecord(init?.headers); + return { ...headersFromRequest, ...headersFromInit }; +}; + +const getRequestDirectoryHint = (url: URL, input?: RequestInfo | URL, init?: RequestInit): string | undefined => { + const queryDirectory = url.searchParams.get('directory') || undefined; + if (queryDirectory) return queryDirectory; + const headers = getRequestHeaders(input, init); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === 'x-opencode-directory') return value; + } + return undefined; +}; + const decodeBase64 = (value: string): Uint8Array => { const binary = atob(value); const bytes = new Uint8Array(binary.length); @@ -324,6 +342,22 @@ const decodeBase64 = (value: string): Uint8Array => { return bytes; }; +const jsonResponse = (body: unknown, status = 200): Response => { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +}; + +const unsupportedWebRouteResponse = (feature: string): Response => { + return jsonResponse({ error: `${feature} is not supported in VS Code` }, 501); +}; + +const pluginConfigErrorStatus = (message: string): number => { + const lower = message.toLowerCase(); + if (lower.includes('already exists')) return 409; + if (lower.includes('not found')) return 404; + if (lower.includes('required') || lower.includes('invalid') || lower.includes('must ')) return 400; + return 500; +}; + const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304; const buildProxiedResponse = ( @@ -341,80 +375,36 @@ const buildProxiedResponse = ( return new Response(body, { status: proxied.status, headers: proxied.headers }); }; -const encodeBase64 = (bytes: Uint8Array): string => { - const CHUNK = 0x8000; - let binary = ''; - for (let i = 0; i < bytes.length; i += CHUNK) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); - } - return btoa(binary); -}; - -const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise => { - if (method === 'GET' || method === 'HEAD') return undefined; - - if (input instanceof Request) { - const cloned = input.clone(); - const buffer = await cloned.arrayBuffer(); - const bytes = new Uint8Array(buffer); - return bytes.length > 0 ? encodeBase64(bytes) : undefined; - } - - const body = init?.body; - if (!body) return undefined; - - if (typeof body === 'string') { - return encodeBase64(new TextEncoder().encode(body)); - } - - if (body instanceof URLSearchParams) { - return encodeBase64(new TextEncoder().encode(body.toString())); - } - - if (body instanceof Blob) { - const buffer = await body.arrayBuffer(); - const bytes = new Uint8Array(buffer); - return bytes.length > 0 ? encodeBase64(bytes) : undefined; - } - - console.warn('[OpenChamber] Unsupported request body type for proxy request:', body); - return undefined; -}; - -const extractBodyText = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise => { - if (method === 'GET' || method === 'HEAD') return ''; - - if (input instanceof Request) { - const cloned = input.clone(); - return await cloned.text(); - } - - const body = init?.body; - if (!body) return ''; - - if (typeof body === 'string') { - return body; - } - - if (body instanceof URLSearchParams) { - return body.toString(); - } - - if (body instanceof Blob) { - return await body.text(); - } - - console.warn('[OpenChamber] Unsupported request body type for direct session proxy:', body); - return ''; -}; - const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event'; const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname); +const isApiPath = (pathname: string) => pathname === '/api' || pathname.startsWith('/api/'); +const isLocalRuntimePath = (pathname: string) => isApiPath(pathname) || pathname === '/auth/session'; -const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { +const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: RequestInit | undefined, method: string) => { const pathname = url.pathname; const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname; - const method = ((init?.method || 'GET') as string).toUpperCase(); + + if (normalizedPathname === '/api/system/info' && method === 'GET') { + const config = window.__VSCODE_CONFIG__; + return jsonResponse({ + openchamberVersion: config?.extensionVersion || 'VS Code Extension', + runtime: 'vscode', + platform: config?.platform || '', + arch: config?.arch || '', + }); + } + + if (normalizedPathname === '/api/preview/targets') { + return unsupportedWebRouteResponse('Preview proxy'); + } + + if (normalizedPathname.startsWith('/api/openchamber/tunnel/')) { + return unsupportedWebRouteResponse('Remote tunnel settings'); + } + + if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) { + return unsupportedWebRouteResponse('Scheduled tasks'); + } if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') { const activity = await sendBridgeMessage>('api:session-activity:get') @@ -575,7 +565,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } if (pathname.startsWith('/api/fs/mkdir')) { - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } @@ -591,7 +581,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } if (pathname.startsWith('/api/vscode/drop-files') && method === 'POST') { - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); const uris = Array.isArray((body as { uris?: unknown[] }).uris) ? (body as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string') : []; @@ -600,7 +590,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } if (pathname.startsWith('/api/vscode/save-image') && method === 'POST') { - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); const fileName = typeof (body as { fileName?: unknown }).fileName === 'string' ? (body as { fileName: string }).fileName : undefined; @@ -612,7 +602,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } if (pathname.startsWith('/api/vscode/save-markdown') && method === 'POST') { - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); const fileName = typeof (body as { fileName?: unknown }).fileName === 'string' ? (body as { fileName: string }).fileName : undefined; @@ -626,29 +616,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { if (pathname.startsWith('/api/config/agents/')) { const encodedName = pathname.slice('/api/config/agents/'.length); const name = decodeURIComponent(encodedName); - const verb = ((init?.method || 'GET') as string).toUpperCase(); - const body = init?.body ? JSON.parse(init.body as string) : {}; - const queryDirectory = url.searchParams.get('directory') || undefined; - const headerDirectory = (() => { - const headers = init?.headers; - if (!headers) return undefined; - if (headers instanceof Headers) { - return headers.get('x-opencode-directory') || undefined; - } - if (Array.isArray(headers)) { - const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory'); - return found?.[1] || undefined; - } - if (typeof headers === 'object') { - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') { - return value; - } - } - } - return undefined; - })(); - const directory = queryDirectory || headerDirectory; + const verb = method; + const body = await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -661,29 +631,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { if (pathname.startsWith('/api/config/commands/')) { const encodedName = pathname.slice('/api/config/commands/'.length); const name = decodeURIComponent(encodedName); - const verb = ((init?.method || 'GET') as string).toUpperCase(); - const body = init?.body ? JSON.parse(init.body as string) : {}; - const queryDirectory = url.searchParams.get('directory') || undefined; - const headerDirectory = (() => { - const headers = init?.headers; - if (!headers) return undefined; - if (headers instanceof Headers) { - return headers.get('x-opencode-directory') || undefined; - } - if (Array.isArray(headers)) { - const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory'); - return found?.[1] || undefined; - } - if (typeof headers === 'object') { - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') { - return value; - } - } - } - return undefined; - })(); - const directory = queryDirectory || headerDirectory; + const verb = method; + const body = await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -694,29 +644,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } if (pathname === '/api/config/mcp') { - const verb = ((init?.method || 'GET') as string).toUpperCase(); - const body = init?.body ? JSON.parse(init.body as string) : {}; - const queryDirectory = url.searchParams.get('directory') || undefined; - const headerDirectory = (() => { - const headers = init?.headers; - if (!headers) return undefined; - if (headers instanceof Headers) { - return headers.get('x-opencode-directory') || undefined; - } - if (Array.isArray(headers)) { - const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory'); - return found?.[1] || undefined; - } - if (typeof headers === 'object') { - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') { - return value; - } - } - } - return undefined; - })(); - const directory = queryDirectory || headerDirectory; + const verb = method; + const body = await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/mcp', { method: verb, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -729,29 +659,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { if (pathname.startsWith('/api/config/mcp/')) { const encodedName = pathname.slice('/api/config/mcp/'.length); const name = decodeURIComponent(encodedName); - const verb = ((init?.method || 'GET') as string).toUpperCase(); - const body = init?.body ? JSON.parse(init.body as string) : {}; - const queryDirectory = url.searchParams.get('directory') || undefined; - const headerDirectory = (() => { - const headers = init?.headers; - if (!headers) return undefined; - if (headers instanceof Headers) { - return headers.get('x-opencode-directory') || undefined; - } - if (Array.isArray(headers)) { - const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory'); - return found?.[1] || undefined; - } - if (typeof headers === 'object') { - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') { - return value; - } - } - } - return undefined; - })(); - const directory = queryDirectory || headerDirectory; + const verb = method; + const body = await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/mcp', { method: verb, name, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -761,13 +671,53 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } } + if (pathname === '/api/config/snippets') { + const verb = method; + const directory = getRequestDirectoryHint(url, input, init); + try { + const data = await sendBridgeMessage('api:config/snippets', { method: verb, directory }); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + + if (pathname === '/api/config/snippets/expand') { + const verb = method === 'GET' && !hasInitBody(init) && !(input instanceof Request) ? 'POST' : method; + const body = await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); + try { + const data = await sendBridgeMessage('api:config/snippets', { method: verb, body, directory }); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + + if (pathname.startsWith('/api/config/snippets/')) { + const encodedName = pathname.slice('/api/config/snippets/'.length); + const name = decodeURIComponent(encodedName); + const verb = method; + const body = await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); + try { + const data = await sendBridgeMessage('api:config/snippets', { method: verb, name, body, directory }); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + // Skills file operations: /api/config/skills/:name/files/:filePath const skillsFilesMatch = pathname.match(/^\/api\/config\/skills\/([^/]+)\/files\/(.+)$/); if (skillsFilesMatch) { const name = decodeURIComponent(skillsFilesMatch[1]); const filePath = decodeURIComponent(skillsFilesMatch[2]); - const verb = ((init?.method || 'GET') as string).toUpperCase(); - const body = init?.body ? JSON.parse(init.body as string) : {}; + const verb = method; + const body = await extractJsonBody(input, init, method); try { const data = await sendBridgeMessage('api:config/skills/files', { method: verb, @@ -808,7 +758,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { // Skills scan: /api/config/skills/scan if (pathname === '/api/config/skills/scan') { - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); try { const data = await sendBridgeMessage('api:config/skills:scan', body); return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } }); @@ -820,7 +770,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { // Skills install: /api/config/skills/install if (pathname === '/api/config/skills/install') { - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); try { const data = await sendBridgeMessage('api:config/skills:install', body); return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } }); @@ -844,8 +794,8 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { if (pathname.startsWith('/api/config/skills/')) { const encodedName = pathname.slice('/api/config/skills/'.length); const name = decodeURIComponent(encodedName); - const verb = ((init?.method || 'GET') as string).toUpperCase(); - const body = init?.body ? JSON.parse(init.body as string) : {}; + const verb = method; + const body = await extractJsonBody(input, init, method); try { const data = await sendBridgeMessage('api:config/skills', { method: verb, name, body }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -856,11 +806,11 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } if (pathname.startsWith('/api/config/settings')) { - if ((init?.method || 'GET').toUpperCase() === 'GET') { + if (method === 'GET') { const settings = await sendBridgeMessage('api:config/settings:get'); return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } }); } - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); const updated = await sendBridgeMessage('api:config/settings:save', body); return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } }); } @@ -871,7 +821,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (method === 'PUT') { - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); const data = await sendBridgeMessage('api:behavior/agents-md:save', body); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } @@ -891,7 +841,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { if (pathname.startsWith('/api/magic-prompts/')) { const id = decodeURIComponent(pathname.slice('/api/magic-prompts/'.length)); if (method === 'PUT') { - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); const data = await sendBridgeMessage('api:magic-prompts:save', { id, text: body?.text }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } @@ -916,6 +866,98 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } + if (pathname === '/api/config/plugins' && method === 'GET') { + try { + const directory = getRequestDirectoryHint(url, input, init); + const data = await sendBridgeMessage('api:config/plugins', { method, target: 'list', directory }); + return jsonResponse(data); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); + } + } + + if (pathname === '/api/config/plugins/registry' && method === 'GET') { + try { + const rawSpecs = url.searchParams.get('specs') || ''; + const specs = rawSpecs ? rawSpecs.split(',').map((spec) => spec.trim()).filter(Boolean) : []; + const directory = getRequestDirectoryHint(url, input, init); + const data = await sendBridgeMessage('api:config/plugins', { + method, + target: 'registry', + specs, + refresh: url.searchParams.get('refresh') === 'true', + directory, + }); + return jsonResponse(data); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); + } + } + + if (pathname === '/api/config/plugins/entry' && method === 'POST') { + try { + const body = await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); + const data = await sendBridgeMessage('api:config/plugins', { method, target: 'entry', body, directory }); + return jsonResponse(data); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); + } + } + + const pluginEntryMatch = pathname.match(/^\/api\/config\/plugins\/entry\/([^/]+)$/); + if (pluginEntryMatch) { + try { + const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); + const data = await sendBridgeMessage('api:config/plugins', { + method, + target: 'entry', + pluginId: decodeURIComponent(pluginEntryMatch[1]), + body, + directory, + }); + return jsonResponse(data); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); + } + } + + if (pathname === '/api/config/plugins/file' && method === 'POST') { + try { + const body = await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); + const data = await sendBridgeMessage('api:config/plugins', { method, target: 'file', body, directory }); + return jsonResponse(data); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); + } + } + + const pluginFileMatch = pathname.match(/^\/api\/config\/plugins\/file\/([^/]+)$/); + if (pluginFileMatch) { + try { + const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method); + const directory = getRequestDirectoryHint(url, input, init); + const data = await sendBridgeMessage('api:config/plugins', { + method, + target: 'file', + pluginId: decodeURIComponent(pluginFileMatch[1]), + body, + directory, + }); + return jsonResponse(data); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); + } + } + if (pathname.startsWith('/api/openchamber/models-metadata')) { try { const data = await sendBridgeMessage('api:models/metadata'); @@ -971,7 +1013,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } if (pathname.startsWith('/api/opencode/directory')) { - const body = init?.body ? JSON.parse(init.body as string) : {}; + const body = await extractJsonBody(input, init, method); const result = await sendBridgeMessage('api:opencode/directory', { path: body.path }); return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } }); } @@ -987,7 +1029,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/); - if (quotaMatch && (init?.method || 'GET').toUpperCase() === 'GET') { + if (quotaMatch && method === 'GET') { const providerId = decodeURIComponent(quotaMatch[1]); try { const data = await sendBridgeMessage('api:quota:get', { providerId }); @@ -1000,7 +1042,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { // Handle provider auth deletion: DELETE /api/provider/:providerId/auth const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/); - if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') { + if (providerAuthMatch && method === 'DELETE') { const providerId = decodeURIComponent(providerAuthMatch[1]); const scope = url.searchParams.get('scope') || 'auth'; const queryDirectory = url.searchParams.get('directory') || undefined; @@ -1015,7 +1057,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { // Handle provider source lookup: GET /api/provider/:providerId/source const providerSourceMatch = pathname.match(/^\/api\/provider\/([^/]+)\/source$/); - if (providerSourceMatch && (init?.method || 'GET').toUpperCase() === 'GET') { + if (providerSourceMatch && method === 'GET') { const providerId = decodeURIComponent(providerSourceMatch[1]); const queryDirectory = url.searchParams.get('directory') || undefined; try { @@ -1036,7 +1078,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase(); const pathname = targetUrl?.pathname || ''; - const normalizedPathname = pathname.replace(/\/+/, '/'); + const normalizedPathname = pathname.replace(/\/{2,}/g, '/'); if (targetUrl && normalizedPathname === '/health') { const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status; const isReady = connectionStatus === 'connected'; @@ -1051,14 +1093,18 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { }); } - if (targetUrl && targetUrl.pathname.startsWith('/api/')) { - const localResponse = await handleLocalApiRequest(targetUrl, init); + if (targetUrl && isLocalRuntimePath(normalizedPathname)) { + const localResponse = await handleLocalApiRequest(input, targetUrl, init, method); if (localResponse) { recordBootstrapFetch(targetUrl.pathname, localResponse.ok); maybeHideLoadingOverlay(); return localResponse; } + if (!isApiPath(normalizedPathname)) { + return originalFetch(input as RequestInfo, init); + } + const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`; const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {}; @@ -1135,7 +1181,8 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) { const bodyText = await extractBodyText(input, init, method); - const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText }); + const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined; + const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText, signal }); const response = buildProxiedResponse(proxied); recordBootstrapFetch(targetUrl.pathname, response.ok); maybeHideLoadingOverlay(); @@ -1143,7 +1190,8 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { } const bodyBase64 = await extractBodyBase64(input, init, method); - const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 }); + const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined; + const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64, signal }); const response = buildProxiedResponse(proxied); recordBootstrapFetch(targetUrl.pathname, response.ok); maybeHideLoadingOverlay(); @@ -1464,14 +1512,7 @@ const fetchLastAssistantMessageText = async (sessionId: string, messageId?: stri if (!sessionId) return ''; try { - const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}/message?limit=5`, { - method: 'GET', - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(3000), - }); - if (!response.ok) return ''; - - const messages = await response.json().catch(() => null) as unknown; + const messages = await opencodeClient.getSessionMessages(sessionId, 5); if (!Array.isArray(messages)) return ''; let target = messageId diff --git a/packages/vscode/webview/requestBodyTransport.test.ts b/packages/vscode/webview/requestBodyTransport.test.ts new file mode 100644 index 00000000..c158c2dd --- /dev/null +++ b/packages/vscode/webview/requestBodyTransport.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test'; +import { extractBodyBase64, extractBodyText } from './requestBodyTransport'; + +const decodeBase64Text = (value: string | undefined): string => { + expect(typeof value).toBe('string'); + const binary = atob(value ?? ''); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder().decode(bytes); +}; + +describe('VS Code webview request body transport', () => { + test('preserves body from SDK-style Request objects', async () => { + const request = new Request('https://openchamber.local/api/session/abc/prompt_async', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ messageID: 'msg_1' }), + }); + + expect(decodeBase64Text(await extractBodyBase64(request, undefined, 'POST'))).toBe('{"messageID":"msg_1"}'); + expect(await request.text()).toBe('{"messageID":"msg_1"}'); + }); + + test('preserves string, URLSearchParams, Blob, ArrayBuffer, typed array, and FormData bodies', async () => { + const cases: Array<{ name: string; init: RequestInit; expected: string | RegExp }> = [ + { name: 'string', init: { body: 'plain text' }, expected: 'plain text' }, + { name: 'URLSearchParams', init: { body: new URLSearchParams({ a: '1', b: 'two' }) }, expected: 'a=1&b=two' }, + { name: 'Blob', init: { body: new Blob(['blob text'], { type: 'text/plain' }) }, expected: 'blob text' }, + { name: 'ArrayBuffer', init: { body: new TextEncoder().encode('array buffer').buffer }, expected: 'array buffer' }, + { name: 'typed array', init: { body: new Uint8Array(new TextEncoder().encode('typed array')) }, expected: 'typed array' }, + ]; + + for (const entry of cases) { + const encoded = await extractBodyBase64('https://openchamber.local/api/test', entry.init, 'POST'); + expect(decodeBase64Text(encoded)).toBe(entry.expected); + } + + const form = new FormData(); + form.set('messageID', 'msg_1'); + form.set('file', new Blob(['file contents'], { type: 'text/plain' }), 'test.txt'); + const encodedForm = await extractBodyBase64('https://openchamber.local/api/upload', { body: form }, 'POST'); + const decodedForm = decodeBase64Text(encodedForm); + expect(decodedForm).toContain('name="messageID"'); + expect(decodedForm).toContain('msg_1'); + expect(decodedForm).toContain('filename="test.txt"'); + expect(decodedForm).toContain('file contents'); + }); + + test('extracts text for direct session message bridge bodies', async () => { + expect(await extractBodyText('https://openchamber.local/api/session/abc/message', { body: new URLSearchParams({ q: 'hello' }) }, 'POST')) + .toBe('q=hello'); + }); +}); diff --git a/packages/vscode/webview/requestBodyTransport.ts b/packages/vscode/webview/requestBodyTransport.ts new file mode 100644 index 00000000..8e5968e5 --- /dev/null +++ b/packages/vscode/webview/requestBodyTransport.ts @@ -0,0 +1,81 @@ +export const encodeBase64 = (bytes: Uint8Array): string => { + const CHUNK = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +}; + +export const hasInitBody = (init: RequestInit | undefined): boolean => init?.body !== undefined && init.body !== null; + +export const readBodyBytes = async (body: BodyInit): Promise => { + if (typeof body === 'string') { + return new TextEncoder().encode(body); + } + + if (body instanceof URLSearchParams) { + return new TextEncoder().encode(body.toString()); + } + + if (body instanceof Blob) { + return new Uint8Array(await body.arrayBuffer()); + } + + if (body instanceof ArrayBuffer) { + return new Uint8Array(body); + } + + if (ArrayBuffer.isView(body)) { + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + } + + if (body instanceof FormData) { + return new Uint8Array(await new Request('https://openchamber.local/body', { method: 'POST', body }).arrayBuffer()); + } + + throw new Error('Unsupported request body type'); +}; + +export const readBodyText = async (body: BodyInit): Promise => { + if (typeof body === 'string') return body; + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof Blob) return await body.text(); + return new TextDecoder().decode(await readBodyBytes(body)); +}; + +export const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise => { + if (method === 'GET' || method === 'HEAD') return undefined; + + if (input instanceof Request && !hasInitBody(init)) { + const cloned = input.clone(); + const buffer = await cloned.arrayBuffer(); + const bytes = new Uint8Array(buffer); + return bytes.length > 0 ? encodeBase64(bytes) : undefined; + } + + const body = init?.body; + if (!body) return undefined; + + const bytes = await readBodyBytes(body); + return bytes.length > 0 ? encodeBase64(bytes) : undefined; +}; + +export const extractBodyText = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise => { + if (method === 'GET' || method === 'HEAD') return ''; + + if (input instanceof Request && !hasInitBody(init)) { + const cloned = input.clone(); + return await cloned.text(); + } + + const body = init?.body; + if (!body) return ''; + + return readBodyText(body); +}; + +export const extractJsonBody = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise> => { + const bodyText = await extractBodyText(input, init, method); + return bodyText ? JSON.parse(bodyText) as Record : {}; +}; diff --git a/packages/web/README.md b/packages/web/README.md index 4e3d7979..bebd9163 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -23,6 +23,7 @@ Or install manually: `bun add -g @openchamber/web` (or npm, pnpm, yarn). ```bash openchamber # Start on port 3000 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 @@ -36,6 +37,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 @@ -52,6 +56,40 @@ openchamber update # Update to latest version - Replacing or stopping a tunnel revokes existing connect links and invalidates remote tunnel sessions. - Connect links are one-time tokens; generating a new link revokes the previous unused link. +### Connect other OpenChamber apps + +Use `connect-url` when a web/API server should be added to OpenChamber Desktop or another OpenChamber app. If no server is running on the selected port, OpenChamber starts one first. + +```bash +openchamber connect-url --port 3000 +openchamber connect-url --port 3000 --qr +openchamber connect-url --port 3000 --json +openchamber connect-url --port 3000 --name "Workstation" +openchamber connect-url --port 3000 --lan --server http://workstation.local:3000 --qr +``` + +### Headless/API-only server for Desktop + +Use this on a remote machine when you want OpenChamber running as a web/API server, then connect to it from OpenChamber Desktop on another machine: + +```bash +openchamber connect-url --port 3000 --api-only --lan --server http://workstation.local:3000 --qr --ui-password your-password +``` + +`--api-only` starts API routes without serving browser UI assets. `--lan` binds the server so other machines can reach it. `--server` is the address saved into the Desktop connection link. `--ui-password` protects browser access if UI routes are enabled elsewhere; the generated client token is what Desktop uses for API access. + +This creates a remote client token and prints an `openchamber://connect?...` link. The link contains the server URL, token, label, and payload version. In OpenChamber Desktop, paste it in **Settings -> Remote Instances -> Direct Instances -> Import Link** to add that server as an Instance. + +If the server was started with `--lan` or `--host 0.0.0.0`, `connect-url` automatically advertises a detected LAN IP instead of `127.0.0.1`. Use `--server ` when you want to advertise a specific DNS name, Tailscale address, reverse proxy URL, or HTTPS endpoint. + +If you are exposing the server beyond localhost, start it with a password: + +```bash +openchamber serve --lan --port 3000 --ui-password your-password +``` + +Generating a client token does not automatically password-protect the hosted browser UI. `--ui-password` protects browser access; the client token lets another OpenChamber app connect to this server. +
Connect to external OpenCode server diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index fa51bfa8..3e045765 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -2,6 +2,7 @@ import fs from 'fs'; import net from 'net'; +import dgram from 'dgram'; import os from 'os'; import path from 'path'; import crypto from 'crypto'; @@ -9,6 +10,7 @@ import { spawn, spawnSync } from 'child_process'; import { fileURLToPath, pathToFileURL } from 'url'; import { isModuleCliExecution } from './cli-entry.js'; import { cloudflareTunnelProviderCapabilities } from '../server/lib/tunnels/providers/cloudflare.js'; +import { createRemoteClientAuthRuntime } from '../server/lib/client-auth/remote-clients.js'; import { intro as clackIntro, outro as clackOutro, log as clackLog, box as clackBox, confirm as clackConfirm, @@ -37,6 +39,7 @@ const TUNNEL_PROFILES_VERSION = 1; const TUNNEL_PROFILES_FILE_NAME = 'tunnel-profiles.json'; const LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME = 'cloudflare-managed-remote-tunnels.json'; const TUNNEL_CLI_STATE_FILE_NAME = 'tunnel-cli-state.json'; +const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json'; const TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS = 30 * 60 * 1000; const TUNNEL_BOOTSTRAP_TTL_MIN_MS = 60 * 1000; const TUNNEL_BOOTSTRAP_TTL_MAX_MS = 24 * 60 * 60 * 1000; @@ -135,10 +138,21 @@ function isUnsafeBrowserPort(port) { return Number.isFinite(port) && UNSAFE_BROWSER_PORTS.has(Math.trunc(port)); } -function resolveApiHost() { - const configured = typeof process.env.OPENCHAMBER_HOST === 'string' - ? process.env.OPENCHAMBER_HOST.trim() - : ''; +function resolveConfiguredBindHost(hostOverride) { + const configured = typeof hostOverride === 'string' && hostOverride.trim() + ? hostOverride.trim() + : typeof process.env.OPENCHAMBER_HOST === 'string' + ? process.env.OPENCHAMBER_HOST.trim() + : ''; + return configured || '127.0.0.1'; +} + +function isWildcardBindHost(host) { + return host === '0.0.0.0' || host === '::' || host === '[::]'; +} + +function resolveApiHost(hostOverride) { + const configured = resolveConfiguredBindHost(hostOverride); if (!configured) { return '127.0.0.1'; @@ -166,12 +180,129 @@ function formatHostForUrl(host) { return host.includes(':') ? `[${host}]` : host; } -function buildLocalUrl(port, endpoint = '') { - const host = formatHostForUrl(resolveApiHost()); +function buildLocalUrl(port, endpoint = '', hostOverride) { + const host = formatHostForUrl(resolveApiHost(hostOverride)); const pathPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; return `http://${host}:${port}${pathPart}`; } +async function detectLanIPv4Address() { + const ip = await new Promise((resolve) => { + const socket = dgram.createSocket('udp4'); + const finish = (value) => { + try { socket.close(); } catch {} + resolve(value); + }; + socket.once('error', () => finish(null)); + try { + socket.connect(80, '8.8.8.8', (error) => { + if (error) return finish(null); + try { + const addr = socket.address(); + finish(addr && typeof addr.address === 'string' ? addr.address : null); + } catch { + finish(null); + } + }); + } catch { + finish(null); + } + }); + + if (ip && ip !== '0.0.0.0' && !ip.startsWith('127.')) return ip; + + for (const entries of Object.values(os.networkInterfaces() || {})) { + for (const entry of entries || []) { + if (entry.family === 'IPv4' && !entry.internal && entry.address) { + return entry.address; + } + } + } + return null; +} + +async function resolveConnectUrlServerUrl(options) { + let hostOverride = options.host; + if (typeof hostOverride !== 'string' && !process.env.OPENCHAMBER_HOST) { + const storedOptions = readInstanceOptions(await getInstanceFilePath(options.port)); + if (typeof storedOptions?.host === 'string' && storedOptions.host.trim()) { + hostOverride = storedOptions.host.trim(); + } + } + + const bindHost = resolveConfiguredBindHost(hostOverride); + if (!isWildcardBindHost(bindHost)) { + return { + serverUrl: buildLocalUrl(options.port, '/', hostOverride).replace(/\/+$/, ''), + source: 'configured-host', + }; + } + + const lanAddress = await detectLanIPv4Address(); + if (!lanAddress) { + return { + serverUrl: buildLocalUrl(options.port, '/').replace(/\/+$/, ''), + source: 'loopback-fallback', + }; + } + + return { + serverUrl: `http://${formatHostForUrl(lanAddress)}:${options.port}`, + source: 'lan-detected', + }; +} + +async function ensureConnectUrlServerRunning(options) { + const running = await discoverRunningInstances(); + if (running.some((entry) => entry.port === options.port)) { + return { port: options.port, autoStarted: false }; + } + + await commands.serve({ + port: options.port, + explicitPort: true, + host: options.host, + uiPassword: options.uiPassword, + apiOnly: options.apiOnly, + suppressUnsafePortWarning: true, + suppressUiPasswordWarning: true, + suppressStartupSummary: true, + suppressQuietOutput: true, + }); + + return { port: options.port, autoStarted: true }; +} + +function normalizeServerUrlForConnection(value) { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + parsed.hash = ''; + return parsed.toString().replace(/\/+$/, ''); + } catch { + return null; + } +} + +function getOpenChamberDataDir() { + return process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'); +} + +function buildClientConnectionPayload({ serverUrl, token, label }) { + const params = new URLSearchParams(); + params.set('v', '1'); + params.set('server', serverUrl.trim().replace(/\/+$/, '')); + params.set('token', token.trim()); + if (label?.trim()) params.set('label', label.trim()); + return `openchamber://connect?${params.toString()}`; +} + function formatUnsafePortWarning(port) { return `Port ${port} is browser-unsafe (ERR_UNSAFE_PORT) and is not supported for OpenChamber UI at ${buildLocalUrl(port, '/')}.`; } @@ -591,6 +722,7 @@ function parseArgs(argv = process.argv.slice(2)) { tokenFile: undefined, tokenStdin: false, hostname: undefined, + server: undefined, connectTtl: undefined, sessionTtl: undefined, qr: false, @@ -604,6 +736,8 @@ function parseArgs(argv = process.argv.slice(2)) { explicitUiPassword: false, envSnapshot: true, foreground: false, + lan: false, + apiOnly: false, }; const removedFlagErrors = []; @@ -676,6 +810,9 @@ function parseArgs(argv = process.argv.slice(2)) { options.host = value.trim(); break; } + case 'lan': + options.lan = true; + break; case 'ui-password': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; @@ -734,6 +871,16 @@ function parseArgs(argv = process.argv.slice(2)) { options.hostname = typeof value === 'string' ? value : options.hostname; break; } + case 'server': + case 'server-url': { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TunnelCliError('Missing value for --server.', EXIT_CODE.USAGE_ERROR); + } + options.server = value.trim(); + break; + } case 'connect-ttl': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; @@ -803,6 +950,9 @@ function parseArgs(argv = process.argv.slice(2)) { case 'no-daemon': options.foreground = true; break; + case 'api-only': + options.apiOnly = true; + break; case 'daemon': case 'd': // Legacy no-op: daemon mode is already the default, but older clients @@ -840,6 +990,14 @@ function parseArgs(argv = process.argv.slice(2)) { const tunnelAction = command === 'tunnel' ? (positional[2] || null) : null; const startupAction = command === 'startup' ? (positional[1] || 'status') : null; + if (options.lan && typeof options.host !== 'string') { + options.host = '0.0.0.0'; + } + + if (command !== 'tunnel' && typeof options.hostname === 'string' && typeof options.host !== 'string') { + options.host = options.hostname; + } + return { command, subcommand, @@ -867,12 +1025,17 @@ COMMANDS: tunnel Tunnel lifecycle commands startup Manage launch at system startup logs Tail OpenChamber logs + connect-url Generate URL/QR for connecting another client update Check for and install updates OPTIONS: -p, --port Web server port (default: ${DEFAULT_PORT}) --host Bind address (default: 127.0.0.1) + --hostname Alias for --host outside tunnel commands + --lan Bind to 0.0.0.0 for LAN access + --server Public/server URL for connect-url links --ui-password Protect browser UI with single password + --api-only Start API routes only, without serving browser UI assets --foreground Run server in foreground (use with systemd/process managers) --no-daemon Alias for --foreground -h, --help Show help @@ -881,6 +1044,7 @@ OPTIONS: ENVIRONMENT: OPENCHAMBER_HOST Bind address (e.g. 0.0.0.0 for all interfaces) OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag + OPENCHAMBER_API_ONLY Set to true/1 to start API routes only OPENCHAMBER_DATA_DIR Override OpenChamber data directory OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096 OPENCODE_PORT Port of external OpenCode server to connect to @@ -890,7 +1054,10 @@ ENVIRONMENT: EXAMPLES: openchamber # Start in daemon mode on default port 3000 (or free port) openchamber --port 8080 # Start on port 8080 (daemon) + openchamber --lan --port 3002 # Start on LAN at 0.0.0.0:3002 openchamber serve --foreground # Start in foreground (for systemd Type=simple) + openchamber connect-url --port 3000 --qr + openchamber connect-url --server https://openchamber.example.com openchamber startup enable # Start OpenChamber at user login openchamber tunnel help # Show tunnel lifecycle help openchamber logs # Follow logs for latest running instance @@ -913,6 +1080,7 @@ OPTIONS: -p, --port Web server port used by startup service --host Bind address used by startup service --ui-password Protect browser UI with single password + --api-only Start API routes only, without serving browser UI assets --no-env-snapshot Do not save current environment for startup service --json Output machine-readable JSON -q, --quiet Suppress non-essential output @@ -920,10 +1088,44 @@ OPTIONS: EXAMPLES: openchamber startup enable openchamber startup enable --port 3000 + openchamber startup enable --port 3000 --api-only --host 0.0.0.0 openchamber startup status --json `); } +function showConnectUrlHelp() { + console.log(` + OpenChamber Connect URL + +USAGE: + openchamber connect-url [OPTIONS] + +DESCRIPTION: + Generate an openchamber:// connection link for adding this server to another + OpenChamber app. If no server is running on the selected port, it starts one. + +OPTIONS: + -p, --port Server port to use or start (default: ${DEFAULT_PORT}) + --host
Bind address when starting the server + --hostname
Alias for --host + --lan Bind to 0.0.0.0 for LAN access when starting + --server Public URL saved into the connection link + --server-url Alias for --server + --name