From 18c5b4c7b5f078431e000b0fab2f54d4045649f4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 6 Jan 2026 21:31:04 +0200 Subject: [PATCH] feat: add multi-project support (#110) * feat: Implement project management store with project path validation and synchronization - Added `useProjectsStore` for managing projects, including adding, removing, renaming, and validating project paths. - Implemented persistence for projects and active project ID using safe storage. - Introduced synchronization from desktop settings to keep project data consistent. - Enhanced session store to manage sessions by directory and added new methods for session management. - Updated todo store to fetch session todos based on the directory context. - Refactored server code to validate and resolve project directories for various API endpoints. - Added project entry validation and sanitization to ensure data integrity. * feat(settings): migrate legacy project settings and update settings loading logic * feat: enhance project management with directory-aware settings and improved agent/command source handling * feat: enhance session and project management with directory-aware settings and improved configuration refresh logic * feat: enhance project management with worktree manager integration and project directory resolution * feat: enhance agent groups store with project directory resolution and loading logic * feat: add heartbeat management and global wrapping for SSE blocks in agent and chat providers * feat: refactor command and project handling in useCommandsStore - Replaced useDirectoryStore with useProjectsStore to manage project paths. - Introduced getRequestDirectory function to determine the active project directory. - Updated command fetching to respect project-level scoping. - Enhanced error handling and logging for command configuration fetching. - Improved command configuration saving and updating to utilize project directory context. feat: enhance project path normalization in useProjectsStore - Added resolveTildePath function to expand paths starting with ~. - Updated normalizeProjectPath to utilize home directory for path expansion. fix: update permission handling in useSessionStore - Changed Permission type to PermissionRequest for clarity. - Updated respondToPermission method to use requestId instead of permissionId. refactor: improve permission utilities - Introduced types for PermissionAction and PermissionRule. - Enhanced getAgentDefinition and resolveConfigStore functions for better type safety. - Added resolvePermissionAction to streamline permission resolution logic. feat: add agent configuration retrieval endpoint - Implemented new API endpoint to fetch agent configuration based on project directory. - Enhanced getAgentPermissionSource to prioritize project-level permissions. chore: update SDK version in package.json files - Bumped @opencode-ai/sdk version to ^1.1.1 across all relevant package.json files. refactor: streamline bridge message handling - Updated handleBridgeMessage to accept directory parameter for agent and command requests. - Improved local API request handling to extract directory from query parameters and headers. feat: enhance project configuration management - Added functions to retrieve and merge project configuration paths. - Improved handling of existing project configuration files for agents and commands. * feat: enhance VSCode integration and session management - Added support for a sticky sidebar header background in light and dark themes. - Introduced functions to read VSCode workspace directory and check if running in VSCode. - Implemented detailed logging for session loading and creation processes. - Enhanced session filtering based on directory structure and canonical paths. - Added a new method to reorder projects and prevent modifications in VSCode workspace. - Improved error handling and logging for app initialization and markdown file parsing. - Updated API checks and health checks to ensure readiness before proceeding. - Refactored code for better readability and maintainability across various modules. * feat: improve agent and branch selection logic, enhance session management, and update multi-run creation response * feat: add worktree management actions in agent group detail and sidebar, including delete and keep only options * fix(ui): share IME guard and cover multi-run * fix(session): reduce maximum visible sessions in group from 7 to 5 --- .github/workflows/release.yml | 41 + CHANGELOG.md | 9 +- bun.lock | 29 +- package.json | 2 +- packages/desktop/src-tauri/Cargo.lock | 61 + packages/desktop/src-tauri/Cargo.toml | 1 + packages/desktop/src-tauri/entitlements.plist | 21 + .../src-tauri/src/assistant_notifications.rs | 155 +- .../desktop/src-tauri/src/commands/files.rs | 138 +- .../src-tauri/src/commands/permissions.rs | 106 +- .../src-tauri/src/commands/settings.rs | 268 +++- packages/desktop/src-tauri/src/main.rs | 426 ++++-- .../desktop/src-tauri/src/opencode_config.rs | 29 +- .../desktop/src-tauri/src/opencode_manager.rs | 8 +- .../desktop/src-tauri/src/session_activity.rs | 49 +- packages/desktop/src-tauri/tauri.conf.json | 1 + packages/desktop/src/main.tsx | 46 +- packages/ui/package.json | 5 +- packages/ui/src/App.tsx | 19 +- packages/ui/src/components/chat/ChatInput.tsx | 108 +- .../ui/src/components/chat/MessageList.tsx | 4 +- .../ui/src/components/chat/ModelControls.tsx | 215 +-- .../ui/src/components/chat/PermissionCard.tsx | 113 +- .../src/components/chat/PermissionRequest.tsx | 6 +- .../chat/message/parts/ToolPart.tsx | 1 - .../ui/src/components/layout/VSCodeLayout.tsx | 44 +- .../src/components/multirun/AgentSelector.tsx | 55 +- .../components/multirun/BranchSelector.tsx | 11 +- .../components/multirun/ModelMultiSelect.tsx | 4 + .../components/multirun/MultiRunLauncher.tsx | 2 +- .../components/sections/agents/AgentsPage.tsx | 1085 ++++++++------ .../sections/agents/AgentsSidebar.tsx | 123 +- .../sections/providers/ProvidersPage.tsx | 10 +- .../session/DirectoryExplorerDialog.tsx | 37 +- .../src/components/session/SessionDialogs.tsx | 92 +- .../src/components/session/SessionSidebar.tsx | 1257 ++++++++++++----- .../src/components/ui/ScrollableOverlay.tsx | 4 +- .../ui/src/components/views/SettingsView.tsx | 158 ++- .../views/agent-manager/AgentGroupDetail.tsx | 113 +- .../agent-manager/AgentManagerEmptyState.tsx | 15 +- .../agent-manager/AgentManagerSidebar.tsx | 184 ++- .../views/agent-manager/AgentManagerView.tsx | 130 +- packages/ui/src/hooks/useEventStream.ts | 512 +++---- packages/ui/src/hooks/useFileSystemAccess.ts | 2 +- packages/ui/src/hooks/useMenuActions.ts | 41 +- packages/ui/src/index.css | 2 + packages/ui/src/lib/api/types.ts | 10 + packages/ui/src/lib/desktop.ts | 8 +- packages/ui/src/lib/ime.ts | 14 + packages/ui/src/lib/opencode/client.ts | 477 ++++++- packages/ui/src/lib/persistence.ts | 68 + packages/ui/src/lib/sessionEvents.ts | 1 + packages/ui/src/stores/messageStore.ts | 12 +- packages/ui/src/stores/permissionStore.ts | 39 +- packages/ui/src/stores/sessionStore.ts | 801 ++++++++--- packages/ui/src/stores/types/sessionTypes.ts | 12 +- packages/ui/src/stores/useAgentGroupsStore.ts | 602 ++++++-- packages/ui/src/stores/useAgentsStore.ts | 300 ++-- packages/ui/src/stores/useCommandsStore.ts | 128 +- packages/ui/src/stores/useConfigStore.ts | 640 +++++++-- packages/ui/src/stores/useDirectoryStore.ts | 157 +- packages/ui/src/stores/useMultiRunStore.ts | 34 +- packages/ui/src/stores/useProjectsStore.ts | 447 ++++++ packages/ui/src/stores/useSessionStore.ts | 23 +- .../ui/src/stores/useSkillsCatalogStore.ts | 7 + packages/ui/src/stores/useSkillsStore.ts | 9 +- packages/ui/src/stores/useTodoStore.ts | 6 +- .../ui/src/stores/utils/permissionUtils.ts | 64 +- packages/ui/src/types/multirun.ts | 2 + packages/ui/src/types/permission.ts | 32 +- packages/vscode/package.json | 2 +- .../vscode/src/AgentManagerPanelProvider.ts | 136 +- packages/vscode/src/ChatViewProvider.ts | 131 +- packages/vscode/src/bridge.ts | 26 +- packages/vscode/src/extension.ts | 7 +- packages/vscode/src/opencode.ts | 335 +++-- packages/vscode/src/opencodeConfig.ts | 43 +- packages/vscode/vite.config.ts | 1 + packages/vscode/webview/api/settings.ts | 2 +- packages/vscode/webview/api/vscode.ts | 2 +- packages/vscode/webview/main.tsx | 84 +- packages/web/package.json | 2 +- packages/web/server/index.js | 508 +++++-- packages/web/server/lib/opencode-config.js | 339 ++++- 84 files changed, 8399 insertions(+), 2854 deletions(-) create mode 100644 packages/desktop/src-tauri/entitlements.plist create mode 100644 packages/ui/src/lib/ime.ts create mode 100644 packages/ui/src/stores/useProjectsStore.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64f9e310..6b7f3b8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,6 +175,47 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + - name: Verify macOS entitlements + run: | + set -euo pipefail + + BUNDLE_DIR="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos" + + if [ ! -d "$BUNDLE_DIR" ]; then + echo "Error: bundle directory not found: $BUNDLE_DIR" + exit 1 + fi + + APP_PATH=$(find "$BUNDLE_DIR" -maxdepth 2 -name "*.app" -print -quit) + if [ -z "$APP_PATH" ]; then + echo "Error: .app bundle not found under $BUNDLE_DIR" + echo "Contents:"; ls -la "$BUNDLE_DIR" + exit 1 + fi + + echo "Verifying app bundle: $APP_PATH" + codesign -vv "$APP_PATH" + + ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>&1 || true) + echo "$ENTITLEMENTS" + + if echo "$ENTITLEMENTS" | grep -q "com.apple.security.app-sandbox"; then + echo "Error: app sandbox entitlement is present" + exit 1 + fi + + for key in \ + com.apple.security.cs.allow-jit \ + com.apple.security.cs.allow-unsigned-executable-memory \ + com.apple.security.cs.disable-executable-page-protection \ + com.apple.security.cs.disable-library-validation + do + if ! echo "$ENTITLEMENTS" | grep -q "$key"; then + echo "Error: required entitlement missing: $key" + exit 1 + fi + done + - name: Prepare release artifacts run: | mkdir -p artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index f514c53c..6def9609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Multi Run / Agent Manager: Select the Agent you want to run for the Worktree sessions -- Agent Manager now submits the promt, if valid similar to the Chat interfaces +- Multi Run / Agent Manager: Select the Agent you want to run for the Worktree sessions (thanks to @wienans). +- Agent Manager now submits the prompt if valid, similar to the Chat interfaces. +- Added worktree management actions in Agent Manager: delete the group or individual worktrees or keep only selected one (thanks to @wienans). +- Fixed IME (Input Method Editor) composition handling for CJK input methods, preventing accidental message send during character conversion (thanks to @madebyjun). +- Added project management with multi-project support and per-project settings for agents/commands/skills. +- Enhanced SSE event stream with heartbeat management, permission bootstrap on connect, and improved reconnection logic. + ## [1.4.3] - 2026-01-04 diff --git a/bun.lock b/bun.lock index 64915208..79130f02 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.209", + "@opencode-ai/sdk": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -74,7 +74,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@openchamber/ui": "workspace:*", "@tauri-apps/plugin-notification": "^2.3.3", @@ -97,12 +97,15 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.209", + "@opencode-ai/sdk": "^1.1.1", "@pierre/diffs": "^1.0.0", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -164,10 +167,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.0.209", + "@opencode-ai/sdk": "^1.1.1", "jsonc-parser": "^3.3.1", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -185,7 +188,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.4.2", + "version": "1.4.3", "bin": { "openchamber": "./bin/cli.js", }, @@ -193,7 +196,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.209", + "@opencode-ai/sdk": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -339,6 +342,14 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -519,7 +530,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.210", "", {}, "sha512-bTHfV4yGxrPkIZFmQrJwKFTE6m8ztvT3S6/zoFY7KrJ10RSxStLYdYLO5e5gVThEmavARSXGiYlZ9nycou+6rQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.1", "", {}, "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ=="], "@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="], diff --git a/package.json b/package.json index a3333c7d..6572b5d0 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.209", + "@opencode-ai/sdk": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index afec3062..658ac3be 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2146,6 +2146,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -2856,6 +2867,7 @@ dependencies = [ "dirs 5.0.1", "fastrand", "futures-util", + "json5", "log", "nix 0.28.0", "objc", @@ -2993,6 +3005,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "phf" version = "0.8.0" @@ -5303,6 +5358,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.1.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 7b0e8b44..5f7d3965 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ reqwest = { version = "0.12.4", default-features = false, features = ["json", "s serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.143" serde_yaml = "0.9" +json5 = "0.4" tauri = { version = "2.9.4", features = ["macos-private-api"] } tauri-plugin-dialog = "2.4.2" tauri-plugin-fs = "2.4.4" diff --git a/packages/desktop/src-tauri/entitlements.plist b/packages/desktop/src-tauri/entitlements.plist new file mode 100644 index 00000000..01f4f44d --- /dev/null +++ b/packages/desktop/src-tauri/entitlements.plist @@ -0,0 +1,21 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-executable-page-protection + + com.apple.security.cs.disable-library-validation + + + diff --git a/packages/desktop/src-tauri/src/assistant_notifications.rs b/packages/desktop/src-tauri/src/assistant_notifications.rs index 4af6d3f9..cf03f448 100644 --- a/packages/desktop/src-tauri/src/assistant_notifications.rs +++ b/packages/desktop/src-tauri/src/assistant_notifications.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, time::Duration}; +use std::{collections::HashSet, path::PathBuf, time::Duration}; use anyhow::Result; use futures_util::TryStreamExt; @@ -11,6 +11,7 @@ use tauri_plugin_notification::NotificationExt; use tokio::{io::AsyncBufReadExt, sync::Mutex}; use tokio_util::io::StreamReader; +use crate::path_utils::expand_tilde_path; use crate::DesktopRuntime; #[derive(Deserialize)] @@ -21,6 +22,14 @@ struct EventEnvelope { properties: Value, } +#[derive(Deserialize)] +struct MultiplexedEventEnvelope { + #[serde(default)] + #[allow(dead_code)] + directory: Option, + payload: EventEnvelope, +} + pub fn spawn_assistant_notifications( app: AppHandle, runtime: DesktopRuntime, @@ -71,41 +80,8 @@ async fn run_once( }; let prefix = opencode.api_prefix(); - let mut url = format!("http://127.0.0.1:{port}{}/event", prefix); - - if let Some(dir) = opencode - .get_working_directory() - .to_str() - .map(|s| s.to_string()) - { - let mut parsed = reqwest::Url::parse(&url)?; - parsed.query_pairs_mut().append_pair("directory", &dir); - url = parsed.to_string(); - } - - debug!("[desktop:notify] Connecting SSE for notifications: {url}"); - - let response = client - .get(&url) - .header("accept", "text/event-stream") - .header("accept-encoding", "identity") - .send() - .await?; - - debug!( - "[desktop:notify] SSE response status={} headers={:?}", - response.status(), - response.headers() - ); - - if !response.status().is_success() { - warn!( - "[desktop:notify] SSE connect failed with status {}", - response.status() - ); - tokio::time::sleep(Duration::from_secs(2)).await; - return Ok(()); - } + let base = format!("http://127.0.0.1:{port}{prefix}"); + let response = connect_notifications_sse(runtime, client, &base).await?; let stream = response .bytes_stream() @@ -142,7 +118,7 @@ async fn run_once( let raw = data_lines.join("\n"); data_lines.clear(); - match serde_json::from_str::(&raw) { + match parse_event_envelope(&raw) { Ok(event) => handle_event(app, event, notified_messages).await, Err(err) => { warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}"); @@ -159,6 +135,111 @@ async fn run_once( Ok(()) } +fn parse_event_envelope(raw: &str) -> Result { + if let Ok(event) = serde_json::from_str::(raw) { + return Ok(event); + } + + let multiplexed = serde_json::from_str::(raw)?; + Ok(multiplexed.payload) +} + +async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option { + let settings = runtime.settings().load().await.ok()?; + + if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) { + if let Some(projects) = settings.get("projects").and_then(Value::as_array) { + if let Some(path) = projects.iter().find_map(|entry| { + let id = entry.get("id").and_then(Value::as_str)?; + if id != active_id { + return None; + } + entry.get("path").and_then(Value::as_str) + }) { + return Some(expand_tilde_path(path)); + } + } + } + + settings + .get("lastDirectory") + .and_then(Value::as_str) + .map(expand_tilde_path) +} + +async fn connect_notifications_sse( + runtime: &DesktopRuntime, + client: &Client, + base: &str, +) -> Result { + let global_url = format!("{base}/global/event"); + match try_connect_sse(client, &global_url, "[desktop:notify]").await { + Ok(response) => { + debug!("[desktop:notify] Using SSE endpoint: {global_url}"); + return Ok(response); + } + Err(err) => { + debug!( + "[desktop:notify] SSE endpoint unavailable: {global_url} ({err:?}); falling back" + ); + } + } + + let event_url = format!("{base}/event"); + match try_connect_sse(client, &event_url, "[desktop:notify]").await { + Ok(response) => { + debug!("[desktop:notify] Using SSE endpoint: {event_url}"); + return Ok(response); + } + Err(err) => { + debug!( + "[desktop:notify] SSE endpoint unavailable: {event_url} ({err:?}); falling back" + ); + } + } + + let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else { + anyhow::bail!("No project directory available for SSE fallback"); + }; + let directory = working_dir.to_string_lossy().to_string(); + let mut parsed = reqwest::Url::parse(&event_url)?; + parsed + .query_pairs_mut() + .append_pair("directory", &directory); + let directory_url = parsed.to_string(); + + let response = try_connect_sse(client, &directory_url, "[desktop:notify]").await?; + debug!("[desktop:notify] Using directory-scoped SSE endpoint: {directory_url}"); + Ok(response) +} + +async fn try_connect_sse( + client: &Client, + url: &str, + log_prefix: &str, +) -> Result { + debug!("{log_prefix} Connecting SSE: {url}"); + + let response = client + .get(url) + .header("accept", "text/event-stream") + .header("accept-encoding", "identity") + .send() + .await?; + + debug!( + "{log_prefix} SSE response status={} headers={:?}", + response.status(), + response.headers() + ); + + if !response.status().is_success() { + anyhow::bail!("SSE connect failed with status {}", response.status()); + } + + Ok(response) +} + async fn handle_event( app: &AppHandle, event: EventEnvelope, diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index a77d8c04..50884ce6 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -136,8 +136,8 @@ pub async fn list_directory( path: Option, state: tauri::State<'_, DesktopRuntime>, ) -> Result { - let workspace_root = resolve_workspace_root(state.settings()).await; - let resolved_path = resolve_sandboxed_path(path, workspace_root.as_ref()) + let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; + let resolved_path = resolve_sandboxed_path(path, &workspace_roots, default_root.as_ref()) .await .map_err(|err| err.to_list_message())?; @@ -150,10 +150,12 @@ pub async fn list_directory( } // Re-check boundary after canonicalization to guard against traversal - if let Some(root) = &workspace_root { - if !resolved_path.starts_with(root) { - return Err(FsCommandError::OutsideWorkspace.to_list_message()); - } + if !workspace_roots.is_empty() + && !workspace_roots + .iter() + .any(|root| resolved_path.starts_with(root)) + { + return Err(FsCommandError::OutsideWorkspace.to_list_message()); } let mut entries = Vec::new(); @@ -223,8 +225,8 @@ pub async fn search_files( max_results: Option, state: tauri::State<'_, DesktopRuntime>, ) -> Result { - let workspace_root = resolve_workspace_root(state.settings()).await; - let resolved_root = resolve_sandboxed_path(directory, workspace_root.as_ref()) + let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; + let resolved_root = resolve_sandboxed_path(directory, &workspace_roots, default_root.as_ref()) .await .map_err(|err| err.to_search_message())?; @@ -352,8 +354,8 @@ pub async fn create_directory( return Err("Path is required".to_string()); } - let workspace_root = resolve_workspace_root(state.settings()).await; - let resolved_path = resolve_creatable_path(trimmed, workspace_root.as_ref()) + let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; + let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref()) .await .map_err(|err| err.to_create_message())?; @@ -369,35 +371,40 @@ pub async fn create_directory( async fn resolve_sandboxed_path( path: Option, - workspace_root: Option<&PathBuf>, + workspace_roots: &[PathBuf], + default_root: Option<&PathBuf>, ) -> Result { let candidate_input = path .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()); - let candidate_path = match (candidate_input, workspace_root) { - (Some(value), _) => expand_tilde_path(value), - (None, Some(root)) => root.clone(), - (None, None) => default_home_directory(), + let fallback_root = default_root + .or_else(|| workspace_roots.first()) + .cloned() + .unwrap_or_else(default_home_directory); + + let candidate_path = match candidate_input { + Some(value) => expand_tilde_path(value), + None => fallback_root.clone(), }; let resolved = if candidate_path.is_absolute() { candidate_path - } else if let Some(root) = workspace_root { - root.join(candidate_path) } else { - default_home_directory().join(candidate_path) + fallback_root.join(candidate_path) }; let canonicalized = fs::canonicalize(&resolved) .await .map_err(FsCommandError::from)?; - if let Some(root) = workspace_root { - if !canonicalized.starts_with(root) { - return Err(FsCommandError::OutsideWorkspace); - } + if !workspace_roots.is_empty() + && !workspace_roots + .iter() + .any(|root| canonicalized.starts_with(root)) + { + return Err(FsCommandError::OutsideWorkspace); } Ok(canonicalized) @@ -405,19 +412,23 @@ async fn resolve_sandboxed_path( async fn resolve_creatable_path( path: &str, - workspace_root: Option<&PathBuf>, + workspace_roots: &[PathBuf], + default_root: Option<&PathBuf>, ) -> Result { let candidate = expand_tilde_path(path); if candidate.as_os_str().is_empty() { return Err(FsCommandError::Other("Path is required".to_string())); } + let fallback_root = default_root + .or_else(|| workspace_roots.first()) + .cloned() + .unwrap_or_else(default_home_directory); + let absolute = if candidate.is_absolute() { candidate - } else if let Some(root) = workspace_root { - root.join(candidate) } else { - default_home_directory().join(candidate) + fallback_root.join(candidate) }; let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?; @@ -426,22 +437,79 @@ async fn resolve_creatable_path( .await .map_err(FsCommandError::from)?; - if let Some(root) = workspace_root { - if !canonical_parent.starts_with(root) { - return Err(FsCommandError::OutsideWorkspace); - } + if !workspace_roots.is_empty() + && !workspace_roots + .iter() + .any(|root| canonical_parent.starts_with(root)) + { + return Err(FsCommandError::OutsideWorkspace); } Ok(absolute) } -async fn resolve_workspace_root(settings: &SettingsStore) -> Option { - if let Ok(Some(last_dir)) = settings.last_directory().await { - if let Ok(canonicalized) = fs::canonicalize(&last_dir).await { - return Some(canonicalized); +async fn resolve_workspace_roots(settings: &SettingsStore) -> (Vec, Option) { + let mut roots: Vec = Vec::new(); + let mut default_root: Option = None; + + let settings_value = settings.load().await.ok(); + + if let Some(value) = settings_value.as_ref() { + if let Some(active_id) = value.get("activeProjectId").and_then(|v| v.as_str()) { + if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) { + if let Some(active_path) = projects.iter().find_map(|entry| { + let id = entry.get("id").and_then(|v| v.as_str())?; + if id != active_id { + return None; + } + entry.get("path").and_then(|v| v.as_str()) + }) { + if let Ok(canonicalized) = + fs::canonicalize(expand_tilde_path(active_path)).await + { + default_root = Some(canonicalized.clone()); + roots.push(canonicalized); + } + } + } + } + + if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) { + for entry in projects { + if let Some(path) = entry.get("path").and_then(|v| v.as_str()) { + if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(path)).await { + roots.push(canonicalized); + } + } + } + } + + if let Some(last_dir) = value.get("lastDirectory").and_then(|v| v.as_str()) { + if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(last_dir)).await { + if default_root.is_none() { + default_root = Some(canonicalized.clone()); + } + roots.push(canonicalized); + } } } - None + + if default_root.is_none() { + if let Ok(Some(last_dir)) = settings.last_directory().await { + if let Ok(canonicalized) = fs::canonicalize(last_dir).await { + default_root = Some(canonicalized); + } + } + } + + let mut deduped: Vec = Vec::new(); + for root in roots { + if !deduped.iter().any(|existing| existing == &root) { + deduped.push(root); + } + } + + (deduped, default_root) } fn default_home_directory() -> PathBuf { diff --git a/packages/desktop/src-tauri/src/commands/permissions.rs b/packages/desktop/src-tauri/src/commands/permissions.rs index bda222e0..85038f50 100644 --- a/packages/desktop/src-tauri/src/commands/permissions.rs +++ b/packages/desktop/src-tauri/src/commands/permissions.rs @@ -1,7 +1,10 @@ +use chrono::Utc; use log::{info, warn}; use serde::{Deserialize, Serialize}; +use serde_json::json; use tauri::AppHandle; use tauri::State; +use uuid::Uuid; use crate::path_utils::expand_tilde_path; use crate::DesktopRuntime; @@ -17,6 +20,8 @@ pub struct DirectoryPermissionRequest { pub struct DirectoryPermissionResult { success: bool, path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + project_id: Option, error: Option, } @@ -27,9 +32,8 @@ pub struct StartAccessingResult { error: Option, } -/// Process directory selection from frontend -/// Updates settings with lastDirectory -/// OpenCode restart is triggered separately via /api/opencode/directory endpoint +/// Process directory selection from frontend. +/// Updates settings (projects, activeProjectId, lastDirectory). #[tauri::command] pub async fn process_directory_selection( path: String, @@ -45,6 +49,7 @@ pub async fn process_directory_selection( return Ok(DirectoryPermissionResult { success: false, path: None, + project_id: None, error: Some("Directory does not exist".to_string()), }); } @@ -53,38 +58,94 @@ pub async fn process_directory_selection( return Ok(DirectoryPermissionResult { success: false, path: None, + project_id: None, error: Some("Path is not a directory".to_string()), }); } - // Update settings with lastDirectory - let mut settings = state - .settings() - .load() - .await - .map_err(|e| format!("Failed to load settings: {}", e))?; + // Update settings with projects + activeProjectId + lastDirectory + let now = Utc::now().timestamp_millis(); + let normalized_path_for_update = normalized_path.clone(); - if let Some(obj) = settings.as_object_mut() { - obj.insert( - "lastDirectory".to_string(), - serde_json::Value::String(normalized_path.clone()), - ); - } - - state + let (_, project_id) = state .settings() - .save(settings) + .update_with(|mut settings| { + if !settings.is_object() { + settings = json!({}); + } + + let project_id = { + let obj = settings.as_object_mut().unwrap(); + + let projects_value = obj.entry("projects").or_insert_with(|| json!([])); + if !projects_value.is_array() { + *projects_value = json!([]); + } + + let projects = projects_value.as_array_mut().unwrap(); + + let existing_index = projects.iter().position(|entry| { + entry + .get("path") + .and_then(|value| value.as_str()) + .map(|value| value == normalized_path_for_update) + .unwrap_or(false) + }); + + if let Some(index) = existing_index { + let entry = projects + .get_mut(index) + .and_then(|value| value.as_object_mut()); + if let Some(entry) = entry { + entry.insert("lastOpenedAt".to_string(), json!(now)); + if let Some(id) = entry.get("id").and_then(|value| value.as_str()) { + id.to_string() + } else { + let id = Uuid::new_v4().to_string(); + entry.insert("id".to_string(), json!(id)); + id + } + } else { + let id = Uuid::new_v4().to_string(); + projects[index] = json!({ + "id": id, + "path": normalized_path_for_update, + "addedAt": now, + "lastOpenedAt": now + }); + id + } + } else { + let id = Uuid::new_v4().to_string(); + projects.push(json!({ + "id": id, + "path": normalized_path_for_update, + "addedAt": now, + "lastOpenedAt": now + })); + id + } + }; + + if let Some(obj) = settings.as_object_mut() { + obj.insert("activeProjectId".to_string(), json!(project_id.clone())); + obj.insert("lastDirectory".to_string(), json!(normalized_path_for_update)); + } + + (settings, project_id) + }) .await .map_err(|e| format!("Failed to save updated settings: {}", e))?; info!( - "[permissions] Updated settings with lastDirectory: {}", - normalized_path + "[permissions] Updated settings with active project {}: {}", + project_id, normalized_path ); Ok(DirectoryPermissionResult { success: true, path: Some(normalized_path), + project_id: Some(project_id), error: None, }) } @@ -98,6 +159,7 @@ pub async fn pick_directory( Ok(DirectoryPermissionResult { success: false, path: None, + project_id: None, error: Some( "Use requestDirectoryAccess instead - it handles native dialog properly".to_string(), ), @@ -122,6 +184,7 @@ pub async fn request_directory_access( return Ok(DirectoryPermissionResult { success: false, path: None, + project_id: None, error: Some("Directory does not exist".to_string()), }); } @@ -130,6 +193,7 @@ pub async fn request_directory_access( return Ok(DirectoryPermissionResult { success: false, path: None, + project_id: None, error: Some("Path is not a directory".to_string()), }); } @@ -139,11 +203,13 @@ pub async fn request_directory_access( Ok(_) => Ok(DirectoryPermissionResult { success: true, path: Some(normalized_path), + project_id: None, error: None, }), Err(e) => Ok(DirectoryPermissionResult { success: false, path: None, + project_id: None, error: Some(format!("Cannot access directory: {}", e)), }), } diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index bf8511d4..b7cea03c 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -1,7 +1,9 @@ use serde::{Deserialize, Serialize}; +use chrono::Utc; use serde_json::{json, Value}; use std::collections::HashSet; use tauri::State; +use uuid::Uuid; use crate::path_utils::expand_tilde_path; use crate::DesktopRuntime; @@ -19,52 +21,47 @@ pub struct RestartResult { restarted: bool, } -/// Load settings from disk (matches Express handler behavior) +/// Load settings from disk. #[tauri::command] pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result { - let settings = state + let (settings, _) = state .settings() - .load() + .update_with(|mut settings| { + migrate_legacy_project_settings(&mut settings); + normalize_project_selection(&mut settings); + (settings, ()) + }) .await .map_err(|e| format!("Failed to load settings: {}", e))?; Ok(SettingsLoadResult { - settings, + settings: format_settings_response(&settings), source: "desktop".to_string(), }) } -/// Save settings to disk with merge logic matching Express implementation +/// Save settings to disk with merge logic. #[tauri::command] pub async fn save_settings( changes: Value, state: State<'_, DesktopRuntime>, ) -> Result { - // Load current settings - let current = state - .settings() - .load() - .await - .map_err(|e| format!("Failed to load current settings: {}", e))?; - - // Sanitize incoming changes let sanitized_changes = sanitize_settings_update(&changes); - // Merge changes into current settings - let merged = merge_persisted_settings(¤t, &sanitized_changes); - - // Save merged settings - state + let (merged, _) = state .settings() - .save(merged.clone()) + .update_with(|current| { + let mut merged = merge_persisted_settings(¤t, &sanitized_changes); + normalize_project_selection(&mut merged); + (merged, ()) + }) .await .map_err(|e| format!("Failed to save settings: {}", e))?; - // Format response Ok(format_settings_response(&merged)) } -/// Restart OpenCode CLI (matches Express /api/config/reload) +/// Restart the backend process (config reload). #[tauri::command] pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result { state @@ -76,6 +73,82 @@ pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result Option { + let arr = value.as_array()?; + let mut seen_ids = HashSet::new(); + let mut seen_paths = HashSet::new(); + let mut result = Vec::new(); + + for entry in arr { + let Some(obj) = entry.as_object() else { + continue; + }; + + let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim(); + let raw_path = obj + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if id.is_empty() || raw_path.is_empty() { + continue; + } + + let expanded = expand_tilde_path(raw_path).to_string_lossy().to_string(); + let normalized = if expanded == "/" { + expanded + } else { + expanded.trim_end_matches('/').replace('\\', "/") + }; + + if normalized.is_empty() { + continue; + } + + if seen_ids.contains(id) || seen_paths.contains(&normalized) { + continue; + } + seen_ids.insert(id.to_string()); + seen_paths.insert(normalized.clone()); + + let mut project = serde_json::Map::new(); + project.insert("id".to_string(), json!(id)); + project.insert("path".to_string(), json!(normalized)); + + if let Some(Value::String(label)) = obj.get("label") { + if !label.trim().is_empty() { + project.insert("label".to_string(), json!(label.trim())); + } + } + if let Some(Value::Number(num)) = obj.get("addedAt") { + if let Some(value) = num.as_i64() { + if value >= 0 { + project.insert("addedAt".to_string(), json!(value)); + } + } + } + if let Some(Value::Number(num)) = obj.get("lastOpenedAt") { + if let Some(value) = num.as_i64() { + if value >= 0 { + project.insert("lastOpenedAt".to_string(), json!(value)); + } + } + } + + result.push(Value::Object(project)); + } + + if arr.is_empty() { + return Some(Value::Array(vec![])); + } + + if result.is_empty() { + None + } else { + Some(Value::Array(result)) + } +} + /// Sanitize settings update payload (port of Express sanitizeSettingsUpdate) fn sanitize_settings_update(payload: &Value) -> Value { let mut result = json!({}); @@ -116,6 +189,14 @@ fn sanitize_settings_update(payload: &Value) -> Value { result_obj.insert("homeDirectory".to_string(), json!(expanded)); } } + if let Some(projects) = obj.get("projects").and_then(sanitize_projects) { + result_obj.insert("projects".to_string(), projects); + } + if let Some(Value::String(s)) = obj.get("activeProjectId") { + if !s.is_empty() { + result_obj.insert("activeProjectId".to_string(), json!(s)); + } + } if let Some(Value::String(s)) = obj.get("uiFont") { if !s.is_empty() { result_obj.insert("uiFont".to_string(), json!(s)); @@ -259,6 +340,135 @@ fn sanitize_settings_update(payload: &Value) -> Value { result } +fn migrate_legacy_project_settings(settings: &mut Value) { + if !settings.is_object() { + *settings = json!({}); + } + + let now = Utc::now().timestamp_millis(); + let obj = settings.as_object_mut().unwrap(); + + let has_projects = obj + .get("projects") + .and_then(|value| value.as_array()) + .map(|arr| !arr.is_empty()) + .unwrap_or(false); + + if has_projects { + return; + } + + let last_directory = obj + .get("lastDirectory") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(expand_tilde_path); + + let Some(mut last_directory) = last_directory else { + return; + }; + + if let Ok(canonicalized) = std::fs::canonicalize(&last_directory) { + last_directory = canonicalized; + } + + let Ok(stats) = std::fs::metadata(&last_directory) else { + return; + }; + if !stats.is_dir() { + return; + } + + let normalized_path = last_directory.to_string_lossy().to_string(); + if normalized_path.trim().is_empty() { + return; + } + + let project_id = Uuid::new_v4().to_string(); + let active_project_id = project_id.clone(); + let project_path = normalized_path.clone(); + + let projects_value = obj.entry("projects").or_insert_with(|| json!([])); + *projects_value = json!([ + { + "id": project_id, + "path": project_path, + "addedAt": now, + "lastOpenedAt": now + } + ]); + + obj.insert("activeProjectId".to_string(), json!(active_project_id)); + + // Ensure approvedDirectories includes the migrated project root. + let approved_value = obj + .entry("approvedDirectories") + .or_insert_with(|| json!([])); + if !approved_value.is_array() { + *approved_value = json!([]); + } + + if let Some(array) = approved_value.as_array_mut() { + array.push(json!(normalized_path.clone())); + array.retain(|entry| entry.as_str().is_some_and(|value| !value.trim().is_empty())); + let mut seen = HashSet::new(); + array.retain(|entry| { + let Some(value) = entry.as_str() else { + return false; + }; + if seen.contains(value) { + return false; + } + seen.insert(value.to_string()); + true + }); + } +} + +fn normalize_project_selection(settings: &mut Value) { + let Some(obj) = settings.as_object_mut() else { + return; + }; + + let Some(projects) = obj.get("projects").and_then(|value| value.as_array()) else { + return; + }; + + if projects.is_empty() { + obj.remove("activeProjectId"); + return; + } + + let current_active = obj + .get("activeProjectId") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + let has_active = projects.iter().any(|entry| { + entry + .get("id") + .and_then(|value| value.as_str()) + .map(|id| id == current_active) + .unwrap_or(false) + }); + + if has_active { + return; + } + + let first_id = projects + .first() + .and_then(|entry| entry.get("id")) + .and_then(|value| value.as_str()); + + if let Some(id) = first_id { + obj.insert("activeProjectId".to_string(), json!(id)); + } else { + obj.remove("activeProjectId"); + } +} + /// Merge persisted settings (port of Express mergePersistedSettings) fn merge_persisted_settings(current: &Value, changes: &Value) -> Value { let mut result = current.clone(); @@ -290,6 +500,22 @@ fn merge_persisted_settings(current: &Value, changes: &Value) -> Value { } } + let project_source = if let Some(Value::Array(arr)) = changes_obj.get("projects") { + Some(arr) + } else { + current.get("projects").and_then(|v| v.as_array()) + }; + + if let Some(entries) = project_source { + for entry in entries { + if let Some(path) = entry.get("path").and_then(|v| v.as_str()) { + if !path.trim().is_empty() { + additional_approved.push(path.trim().to_string()); + } + } + } + } + let mut approved_set: HashSet = base_approved.into_iter().collect(); for item in additional_approved { approved_set.insert(item); diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index f3e938f0..68a60d89 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -22,9 +22,9 @@ use anyhow::{anyhow, Result}; use assistant_notifications::spawn_assistant_notifications; use axum::{ body::{to_bytes, Body}, - extract::{OriginalUri, State}, - http::{Method, Request, Response, StatusCode}, - response::IntoResponse, + extract::{Request, State}, + http::{Method, StatusCode}, + response::{IntoResponse, Response}, routing::{any, get, post}, Json, Router, }; @@ -39,6 +39,7 @@ use commands::git::{ update_git_identity, }; use commands::logs::fetch_desktop_logs; + use commands::notifications::desktop_notify; use commands::permissions::{ pick_directory, process_directory_selection, request_directory_access, @@ -157,10 +158,7 @@ pub(crate) struct DesktopRuntime { impl DesktopRuntime { fn initialize_sync() -> Result { let settings = Arc::new(SettingsStore::new()?); - let initial_dir = tauri::async_runtime::block_on(settings.last_directory()) - .ok() - .flatten(); - let opencode = Arc::new(OpenCodeManager::new_with_directory(initial_dir.clone())); + let opencode = Arc::new(OpenCodeManager::new_with_directory(None)); let client = Client::builder().build()?; @@ -170,6 +168,7 @@ impl DesktopRuntime { let server_state = ServerState { client, opencode: opencode.clone(), + settings: settings.clone(), server_port, directory_change_lock: Arc::new(Mutex::new(())), models_metadata_cache: Arc::new(Mutex::new(ModelsMetadataCache::default())), @@ -217,6 +216,7 @@ impl DesktopRuntime { struct ServerState { client: Client, opencode: Arc, + settings: Arc, server_port: u16, directory_change_lock: Arc>, models_metadata_cache: Arc>, @@ -420,21 +420,11 @@ fn build_macos_menu( )?; // View menu items - let open_git_tab = MenuItem::with_id( - app, - MENU_ITEM_OPEN_GIT_TAB_ID, - "Git", - true, - Some("Cmd+G"), - )?; + let open_git_tab = + MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Cmd+G"))?; - let open_diff_tab = MenuItem::with_id( - app, - MENU_ITEM_OPEN_DIFF_TAB_ID, - "Diff", - true, - Some("Cmd+E"), - )?; + let open_diff_tab = + MenuItem::with_id(app, MENU_ITEM_OPEN_DIFF_TAB_ID, "Diff", true, Some("Cmd+E"))?; let open_terminal_tab = MenuItem::with_id( app, @@ -718,18 +708,8 @@ fn main() { let app_handle = app.app_handle().clone(); let runtime_clone = runtime.clone(); - let has_initial_dir = - tauri::async_runtime::block_on(runtime.settings().last_directory()) - .ok() - .flatten() - .is_some(); tauri::async_runtime::spawn(async move { - // Only start opencode if we have a saved directory, otherwise frontend will prompt - if has_initial_dir { - runtime_clone.start_opencode().await; - } else { - info!("[desktop] No saved directory - waiting for user to select one"); - } + runtime_clone.start_opencode().await; if let Err(e) = restore_bookmarks_on_startup(app_handle.state::().clone()).await @@ -1190,11 +1170,11 @@ struct DirectoryChangeResponse { path: String, } -fn json_response(status: StatusCode, payload: T) -> Response { +fn json_response(status: StatusCode, payload: T) -> Response { (status, Json(payload)).into_response() } -fn config_error_response(status: StatusCode, message: impl Into) -> Response { +fn config_error_response(status: StatusCode, message: impl Into) -> Response { json_response( status, ConfigErrorResponse { @@ -1203,10 +1183,8 @@ fn config_error_response(status: StatusCode, message: impl Into) -> Resp ) } -async fn parse_request_payload( - req: Request, -) -> Result, Response> { - let (_, body) = req.into_parts(); +async fn parse_request_payload(req: &mut Request) -> Result, Response> { + let body = std::mem::take(req.body_mut()); let body_bytes = to_bytes(body, PROXY_BODY_LIMIT) .await .map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Invalid request body"))?; @@ -1222,7 +1200,7 @@ async fn parse_request_payload( async fn refresh_opencode_after_config_change( state: &ServerState, reason: &str, -) -> Result<(), Response> { +) -> Result<(), Response> { info!("[desktop:config] Restarting OpenCode after {}", reason); state.opencode.restart().await.map_err(|err| { config_error_response( @@ -1233,21 +1211,146 @@ async fn refresh_opencode_after_config_change( Ok(()) } +fn extract_directory_from_request(req: &Request) -> Option { + if let Some(value) = req.headers().get("x-opencode-directory") { + if let Ok(text) = value.to_str() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + let query = req.uri().query()?; + for pair in query.split('&') { + let mut parts = pair.splitn(2, '='); + let key = parts.next()?; + if key != "directory" { + continue; + } + let value = parts.next().unwrap_or(""); + if let Ok(decoded) = urlencoding::decode(value) { + let trimmed = decoded.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + None +} + +async fn resolve_directory_candidate(candidate: &str) -> Result { + let mut resolved = expand_tilde_path(candidate); + if !resolved.is_absolute() { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + resolved = home.join(resolved); + } + + let metadata = fs::metadata(&resolved) + .await + .map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Directory not found"))?; + if !metadata.is_dir() { + return Err(config_error_response( + StatusCode::BAD_REQUEST, + "Specified path is not a directory", + )); + } + + if let Ok(canonicalized) = fs::canonicalize(&resolved).await { + resolved = canonicalized; + } + + Ok(resolved) +} + +async fn resolve_project_directory_from_settings( + settings: &SettingsStore, +) -> Result, Response> { + let raw = settings.load().await.map_err(|_| { + config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load settings") + })?; + + let active_id = raw + .get("activeProjectId") + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + let projects = raw.get("projects").and_then(|value| value.as_array()); + if let Some(projects) = projects { + if let Some(entry) = projects.iter().find(|entry| { + entry + .get("id") + .and_then(|value| value.as_str()) + .map(|value| value.trim() == active_id) + .unwrap_or(false) + }) { + if let Some(path) = entry.get("path").and_then(|value| value.as_str()) { + return resolve_directory_candidate(path).await.map(Some); + } + } + + if let Some(entry) = projects.first() { + if let Some(path) = entry.get("path").and_then(|value| value.as_str()) { + return resolve_directory_candidate(path).await.map(Some); + } + } + } + + let legacy = raw + .get("lastDirectory") + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim(); + if !legacy.is_empty() { + return resolve_directory_candidate(legacy).await.map(Some); + } + + Ok(None) +} + +async fn resolve_project_directory( + state: &ServerState, + directory: Option, +) -> Result { + if let Some(directory) = directory { + return resolve_directory_candidate(&directory).await; + } + + match resolve_project_directory_from_settings(state.settings.as_ref()).await? { + Some(path) => Ok(path), + None => Err(config_error_response( + StatusCode::BAD_REQUEST, + "Directory parameter or active project is required", + )), + } +} + async fn handle_agent_route( state: &ServerState, method: Method, - req: Request, + mut req: Request, name: String, -) -> Result, StatusCode> { +) -> Result { // Get working directory for project-level agent detection - let working_directory = state.opencode.get_working_directory(); - + let working_directory = + match resolve_project_directory(state, extract_directory_from_request(&req)).await { + Ok(directory) => directory, + Err(response) => return Ok(response), + }; match method { Method::GET => { match opencode_config::get_agent_sources(&name, Some(&working_directory)).await { Ok(sources) => { - let scope = sources.md.scope.clone().map(|s| match s { + let resolved_scope = if sources.md.exists { + sources.md.scope.clone() + } else { + sources.json.scope.clone() + }; + let scope = resolved_scope.map(|s| match s { opencode_config::Scope::User => opencode_config::CommandScope::User, opencode_config::Scope::Project => opencode_config::CommandScope::Project, }); @@ -1271,12 +1374,11 @@ async fn handle_agent_route( } } Method::POST => { - let payload = match parse_request_payload(req).await { + let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; - // Extract scope from payload if present let scope = payload .get("scope") @@ -1320,7 +1422,7 @@ async fn handle_agent_route( } } Method::PATCH => { - let payload = match parse_request_payload(req).await { + let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; @@ -1421,11 +1523,17 @@ struct SkillFileResponse { content: String, } -async fn handle_skill_list_route(state: &ServerState) -> Result, StatusCode> { - let working_directory = state.opencode.get_working_directory(); +async fn handle_skill_list_route( + state: &ServerState, + req: Request, +) -> Result { + let working_directory = + match resolve_project_directory(state, extract_directory_from_request(&req)).await { + Ok(directory) => directory, + Err(response) => return Ok(response), + }; let discovered = opencode_config::discover_skills(Some(&working_directory)); - let mut skills = Vec::new(); for skill in discovered { match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await { @@ -1447,18 +1555,24 @@ async fn handle_skill_list_route(state: &ServerState) -> Result, } } - Ok(json_response(StatusCode::OK, serde_json::json!({ "skills": skills }))) + Ok(json_response( + StatusCode::OK, + serde_json::json!({ "skills": skills }), + )) } async fn handle_skill_route( state: &ServerState, method: Method, - req: Request, + mut req: Request, name: String, file_path: Option, -) -> Result, StatusCode> { - let working_directory = state.opencode.get_working_directory(); - +) -> Result { + let working_directory = + match resolve_project_directory(state, extract_directory_from_request(&req)).await { + Ok(directory) => directory, + Err(response) => return Ok(response), + }; // Handle file operations: /api/config/skills/:name/files/* if let Some(ref fp) = file_path { @@ -1504,11 +1618,14 @@ async fn handle_skill_route( } Method::PUT => { // Write supporting file - let payload = match parse_request_payload(req).await { + let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; - let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let content = payload + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or(""); match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { Ok(sources) => { @@ -1616,12 +1733,13 @@ async fn handle_skill_route( } } Method::POST => { - let payload = match parse_request_payload(req).await { + let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; - let scope = payload.get("scope") + let scope = payload + .get("scope") .and_then(|v| v.as_str()) .and_then(|s| match s { "project" => Some(opencode_config::SkillScope::Project), @@ -1667,7 +1785,7 @@ async fn handle_skill_route( } } Method::PATCH => { - let payload = match parse_request_payload(req).await { + let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; @@ -1744,18 +1862,26 @@ async fn handle_skill_route( async fn handle_command_route( state: &ServerState, method: Method, - req: Request, + mut req: Request, name: String, -) -> Result, StatusCode> { +) -> Result { // Get working directory for project-level command detection - let working_directory = state.opencode.get_working_directory(); - + let working_directory = + match resolve_project_directory(state, extract_directory_from_request(&req)).await { + Ok(directory) => directory, + Err(response) => return Ok(response), + }; match method { Method::GET => { match opencode_config::get_command_sources(&name, Some(&working_directory)).await { Ok(sources) => { - let scope = sources.md.scope.clone().map(|s| match s { + let resolved_scope = if sources.md.exists { + sources.md.scope.clone() + } else { + sources.json.scope.clone() + }; + let scope = resolved_scope.map(|s| match s { opencode_config::Scope::User => opencode_config::CommandScope::User, opencode_config::Scope::Project => opencode_config::CommandScope::Project, }); @@ -1779,12 +1905,11 @@ async fn handle_command_route( } } Method::POST => { - let payload = match parse_request_payload(req).await { + let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; - // Extract scope from payload if present let scope = payload .get("scope") @@ -1831,7 +1956,7 @@ async fn handle_command_route( } } Method::PATCH => { - let payload = match parse_request_payload(req).await { + let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; @@ -1913,8 +2038,8 @@ async fn handle_config_routes( state: ServerState, path: &str, method: Method, - req: Request, -) -> Result, StatusCode> { + mut req: Request, +) -> Result { if let Some(name) = path.strip_prefix("/api/config/agents/") { let trimmed = name.trim(); if trimmed.is_empty() { @@ -1945,13 +2070,17 @@ async fn handle_config_routes( .map(|q| q.contains("refresh=true")) .unwrap_or(false); - let working_directory = state.opencode.get_working_directory(); + let working_directory = + match resolve_project_directory(&state, extract_directory_from_request(&req)).await { + Ok(directory) => directory, + Err(response) => return Ok(response), + }; let payload = skills_catalog::get_catalog(&working_directory, refresh).await; return Ok(json_response(StatusCode::OK, payload)); } if path == "/api/config/skills/scan" && method == Method::POST { - let payload_map = match parse_request_payload(req).await { + let payload_map = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; @@ -1991,7 +2120,7 @@ async fn handle_config_routes( } if path == "/api/config/skills/install" && method == Method::POST { - let payload_map = match parse_request_payload(req).await { + let payload_map = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; @@ -2019,15 +2148,22 @@ async fn handle_config_routes( } }; - let working_directory = state.opencode.get_working_directory(); - let response = skills_catalog::install_skills(&working_directory, install_request).await; + let working_directory = if install_request.scope == "project" { + match resolve_project_directory(&state, extract_directory_from_request(&req)).await { + Ok(directory) => directory, + Err(response) => return Ok(response), + } + } else { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) + }; + let response = skills_catalog::install_skills(&working_directory, install_request).await; let status = if response.ok { StatusCode::OK - } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") { - StatusCode::CONFLICT } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") { StatusCode::UNAUTHORIZED + } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") { + StatusCode::CONFLICT } else { StatusCode::BAD_REQUEST }; @@ -2037,7 +2173,7 @@ async fn handle_config_routes( // Handle skill routes: /api/config/skills and /api/config/skills/:name if path == "/api/config/skills" && method == Method::GET { - return handle_skill_list_route(&state).await; + return handle_skill_list_route(&state, req).await; } if let Some(rest) = path.strip_prefix("/api/config/skills/") { @@ -2059,7 +2195,6 @@ async fn handle_config_routes( .await; } - let trimmed = rest.trim(); if trimmed.is_empty() { return Ok(config_error_response( @@ -2158,7 +2293,8 @@ async fn change_directory_handler( let mut resolved_path = expand_tilde_path(requested_path); if !resolved_path.is_absolute() { - resolved_path = state.opencode.get_working_directory().join(resolved_path); + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + resolved_path = home.join(resolved_path); } // Validate directory exists and is accessible @@ -2185,51 +2321,72 @@ async fn change_directory_handler( resolved_path = canonicalized; } - let current_dir = state.opencode.get_working_directory(); - let is_running = state.opencode.current_port().is_some(); + let path_value = resolved_path.to_string_lossy().to_string(); - // If already on this directory and OpenCode is running, no restart needed - if current_dir == resolved_path && is_running { - return Ok(Json(DirectoryChangeResponse { - success: true, - restarted: false, - path: resolved_path.to_string_lossy().to_string(), - })); - } - - info!("[desktop:http] Changing directory to {:?}", resolved_path); - - // Update working directory and restart OpenCode state - .opencode - .set_working_directory(resolved_path.clone()) - .await - .map_err(|e| { - error!( - "[desktop:http] ERROR: Failed to set working directory: {}", - e - ); - StatusCode::INTERNAL_SERVER_ERROR - })?; + .settings + .update(|mut settings| { + if !settings.is_object() { + settings = Value::Object(Default::default()); + } - state.opencode.restart().await.map_err(|e| { - error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let mut projects = settings + .get("projects") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + + let existing_index = projects.iter().position(|entry| { + entry + .get("path") + .and_then(|value| value.as_str()) + .map(|value| value == path_value) + .unwrap_or(false) + }); + + let active_project_id = if let Some(index) = existing_index { + projects[index] + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string() + } else { + let id = uuid::Uuid::new_v4().to_string(); + let project = serde_json::json!({ + "id": id, + "path": path_value, + "addedAt": chrono::Utc::now().timestamp_millis(), + "lastOpenedAt": chrono::Utc::now().timestamp_millis(), + }); + projects.push(project); + id + }; + + let map = settings.as_object_mut().unwrap(); + map.insert("projects".to_string(), Value::Array(projects)); + map.insert( + "activeProjectId".to_string(), + Value::String(active_project_id.clone()), + ); + map.insert("lastDirectory".to_string(), Value::String(path_value.clone())); + + settings + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(DirectoryChangeResponse { success: true, - restarted: true, - path: resolved_path.to_string_lossy().to_string(), + restarted: false, + path: path_value, })) } async fn proxy_to_opencode( State(state): State, - original: OriginalUri, - req: Request, -) -> Result, StatusCode> { - let origin_path = original.0.path().to_string(); + req: Request, +) -> Result { + let origin_path = req.uri().path().to_string(); let method = req.method().clone(); // Check if this is a provider auth deletion request (DELETE /api/provider/:id/auth) @@ -2253,7 +2410,7 @@ async fn proxy_to_opencode( StatusCode::SERVICE_UNAVAILABLE })?; - let query = original.0.query(); + let query = req.uri().query(); let rewritten_path = state.opencode.rewrite_path(&origin_path); let mut target = format!("http://127.0.0.1:{port}{rewritten_path}"); if let Some(q) = query { @@ -2351,14 +2508,41 @@ impl SettingsStore { } } - pub(crate) async fn save(&self, payload: Value) -> Result<()> { + + pub(crate) async fn update_with(&self, f: F) -> Result<(Value, R)> + where + F: FnOnce(Value) -> (Value, R), + { let _lock = self.guard.lock().await; - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent).await.ok(); + + let current = match fs::read(&self.path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Value::Object(Default::default()) + } + Err(err) => return Err(err.into()), + }; + + let current_snapshot = current.clone(); + let (next, result) = f(current); + + if next != current_snapshot { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).await.ok(); + } + let bytes = serde_json::to_vec_pretty(&next)?; + fs::write(&self.path, bytes).await?; } - let bytes = serde_json::to_vec_pretty(&payload)?; - fs::write(&self.path, bytes).await?; - Ok(()) + + Ok((next, result)) + } + + pub(crate) async fn update(&self, f: F) -> Result + where + F: FnOnce(Value) -> Value, + { + let (next, _) = self.update_with(|current| (f(current), ())).await?; + Ok(next) } pub(crate) async fn last_directory(&self) -> Result> { diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs index f61e0281..dfaa7e12 100644 --- a/packages/desktop/src-tauri/src/opencode_config.rs +++ b/packages/desktop/src-tauri/src/opencode_config.rs @@ -105,9 +105,30 @@ fn get_config_file() -> PathBuf { get_config_dir().join("opencode.json") } -/// Get project config file path +/// Get all possible project config paths in priority order +/// Priority: root > .opencode/, json > jsonc +fn get_project_config_candidates(working_directory: &Path) -> Vec { + vec![ + working_directory.join("opencode.json"), + working_directory.join("opencode.jsonc"), + working_directory.join(".opencode").join("opencode.json"), + working_directory.join(".opencode").join("opencode.jsonc"), + ] +} + +/// Find existing project config file or return default path for new config fn get_project_config_file(working_directory: &Path) -> PathBuf { - working_directory.join("opencode.json") + let candidates = get_project_config_candidates(working_directory); + + // Return first existing config file + for candidate in &candidates { + if candidate.exists() { + return candidate.clone(); + } + } + + // Default to root opencode.json for new configs + candidates.into_iter().next().unwrap_or_else(|| working_directory.join("opencode.json")) } /// Get custom config file path from OPENCODE_CONFIG env var @@ -165,7 +186,9 @@ async fn read_config_file(path: &Path) -> Result { return Ok(Value::Object(serde_json::Map::new())); } - serde_json::from_str(&normalized).map_err(|e| anyhow!("Failed to parse config: {}", e)) + serde_json::from_str(&normalized) + .or_else(|_| json5::from_str::(&normalized)) + .map_err(|e| anyhow!("Failed to parse config: {}", e)) } async fn read_config_layers(working_directory: Option<&Path>) -> Result { diff --git a/packages/desktop/src-tauri/src/opencode_manager.rs b/packages/desktop/src-tauri/src/opencode_manager.rs index 078d202e..9261a164 100644 --- a/packages/desktop/src-tauri/src/opencode_manager.rs +++ b/packages/desktop/src-tauri/src/opencode_manager.rs @@ -57,7 +57,7 @@ fn normalize_api_prefix(prefix: &str) -> String { } impl OpenCodeManager { - pub fn new_with_directory(initial_dir: Option) -> Self { + pub fn new_with_directory(_initial_dir: Option) -> Self { let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT") .ok() .and_then(|raw| raw.parse::().ok()) @@ -88,7 +88,7 @@ impl OpenCodeManager { } let env = build_augmented_env(); - let working_dir = initial_dir + let working_dir = dirs::home_dir() .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); info!( @@ -177,11 +177,13 @@ impl OpenCodeManager { self.graceful_stop().await } + #[allow(dead_code)] pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> { *self.working_dir.write() = new_dir; Ok(()) } + #[allow(dead_code)] pub fn get_working_directory(&self) -> PathBuf { self.working_dir.read().clone() } @@ -191,7 +193,7 @@ impl OpenCodeManager { return Err(anyhow!("Cannot detect API prefix without port")); }; - // Try empty prefix first (OpenCode default), then /api (some installations) + // Try no prefix first, then /api (compatibility). let candidates = ["", "/api"]; for candidate in candidates { let base = if candidate.is_empty() { diff --git a/packages/desktop/src-tauri/src/session_activity.rs b/packages/desktop/src-tauri/src/session_activity.rs index 9f357990..e319509b 100644 --- a/packages/desktop/src-tauri/src/session_activity.rs +++ b/packages/desktop/src-tauri/src/session_activity.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; use anyhow::Result; use futures_util::TryStreamExt; @@ -10,6 +10,7 @@ use tauri::{AppHandle, Emitter}; use tokio::sync::Mutex; use tokio_util::io::StreamReader; +use crate::path_utils::expand_tilde_path; use crate::DesktopRuntime; #[derive(Deserialize)] @@ -126,13 +127,16 @@ async fn run_once( // No data received recently; if we are connected to a directory-scoped stream and the working directory // has changed, reconnect so activity tracking follows the new directory. if let SseScope::Directory(connected_dir) = &scope { - let current_dir = opencode.get_working_directory(); - if current_dir != *connected_dir { - debug!( - "[desktop:activity] Working directory changed; reconnecting activity SSE (from {:?} to {:?})", - connected_dir, current_dir - ); - return Ok(()); + if let Some(current_dir) = + resolve_project_directory_from_settings(runtime).await + { + if current_dir != *connected_dir { + debug!( + "[desktop:activity] Project directory changed; reconnecting activity SSE (from {:?} to {:?})", + connected_dir, current_dir + ); + return Ok(()); + } } } continue; @@ -183,13 +187,34 @@ fn parse_event_envelope(raw: &str) -> Result<(EventEnvelope, Option)> { Ok((multiplexed.payload, multiplexed.directory)) } +async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option { + let settings = runtime.settings().load().await.ok()?; + + if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) { + if let Some(projects) = settings.get("projects").and_then(Value::as_array) { + if let Some(path) = projects.iter().find_map(|entry| { + let id = entry.get("id").and_then(Value::as_str)?; + if id != active_id { + return None; + } + entry.get("path").and_then(Value::as_str) + }) { + return Some(expand_tilde_path(path)); + } + } + } + + settings + .get("lastDirectory") + .and_then(Value::as_str) + .map(expand_tilde_path) +} + async fn connect_activity_sse( runtime: &DesktopRuntime, client: &Client, base: &str, ) -> Result<(reqwest::Response, SseScope)> { - let opencode = runtime.opencode_manager(); - let global_url = format!("{base}/global/event"); match try_connect_sse(client, &global_url, "[desktop:activity]").await { Ok(response) => { @@ -216,7 +241,9 @@ async fn connect_activity_sse( } } - let working_dir = opencode.get_working_directory(); + let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else { + anyhow::bail!("No project directory available for SSE fallback"); + }; let directory = working_dir.to_string_lossy().to_string(); let mut parsed = reqwest::Url::parse(&event_url)?; parsed diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index e6a6a997..3a0d6d28 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -45,6 +45,7 @@ "exceptionDomain": "localhost", "minimumSystemVersion": "14.0", "signingIdentity": null, + "entitlements": "./entitlements.plist", "infoPlist": "Info.plist", "dmg": { "appPosition": { diff --git a/packages/desktop/src/main.tsx b/packages/desktop/src/main.tsx index e7eb14be..c69396c3 100644 --- a/packages/desktop/src/main.tsx +++ b/packages/desktop/src/main.tsx @@ -144,22 +144,12 @@ window.opencodeDesktop = { } }, async getSettings(): Promise { - try { - const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings'); - return result.settings; - } catch (error) { - console.error('[desktop] Error loading settings:', error); - return {} as DesktopSettings; - } + const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings'); + return result.settings; }, async updateSettings(changes: Partial): Promise { - try { - const result = await invoke('save_settings', { changes }); - return result; - } catch (error) { - console.error('[desktop] Error updating settings:', error); - return {}; - } + const result = await invoke('save_settings', { changes }); + return result; }, async restartOpenCode() { try { @@ -188,22 +178,42 @@ window.opencodeDesktop = { markRendererReady() { }, - async requestDirectoryAccess() { + async requestDirectoryAccess(directoryPath?: string) { try { + const normalized = typeof directoryPath === 'string' ? directoryPath.trim() : ''; + + // When the UI already picked a path (typed / directory tree), skip native dialog. + if (normalized.length > 0) { + const result = await invoke<{ + success: boolean; + path?: string; + projectId?: string; + error?: string; + }>('process_directory_selection', { + path: normalized, + }); + + return result; + } const { open } = await import('@tauri-apps/plugin-dialog'); const selected = await open({ directory: true, multiple: false, - title: 'Select Working Directory' + title: 'Select Working Directory', }); if (!selected || typeof selected !== 'string') { return { success: false, error: 'Directory selection cancelled' }; } - const result = await invoke<{ success: boolean; path?: string; error?: string }>('process_directory_selection', { - path: selected + const result = await invoke<{ + success: boolean; + path?: string; + projectId?: string; + error?: string; + }>('process_directory_selection', { + path: selected, }); return result; diff --git a/packages/ui/package.json b/packages/ui/package.json index f4a0ba15..ed8f2902 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,10 +11,13 @@ "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.209", + "@opencode-ai/sdk": "^1.1.1", "@pierre/diffs": "^1.0.0", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index f6d60585..0945c051 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -44,7 +44,7 @@ type AppProps = { }; function App({ apis }: AppProps) { - const { initializeApp, isInitialized } = useConfigStore(); + const { initializeApp, isInitialized, isConnected } = useConfigStore(); const { error, clearError, loadSessions } = useSessionStore(); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory); @@ -121,11 +121,16 @@ function App({ apis }: AppProps) { React.useEffect(() => { const init = async () => { + // VS Code runtime bootstraps config + sessions after the managed OpenCode instance reports "connected". + // Doing the default initialization here can race with startup and lead to one-shot failures. + if (isVSCodeRuntime) { + return; + } await initializeApp(); }; init(); - }, [initializeApp]); + }, [initializeApp, isVSCodeRuntime]); React.useEffect(() => { if (isSwitchingDirectory) { @@ -133,13 +138,21 @@ function App({ apis }: AppProps) { } const syncDirectoryAndSessions = async () => { + // VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races. + if (isVSCodeRuntime) { + return; + } + + if (!isConnected) { + return; + } opencodeClient.setDirectory(currentDirectory); await loadSessions(); }; syncDirectoryAndSessions(); - }, [currentDirectory, isSwitchingDirectory, loadSessions]); + }, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]); useEventStream(); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 7616c8f5..e4138a3f 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -30,6 +30,7 @@ import { toast } from 'sonner'; import { useFileStore } from '@/stores/fileStore'; import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { isIMECompositionEvent } from '@/lib/ime'; import { DropdownMenu, DropdownMenuContent, @@ -47,14 +48,68 @@ interface ChatInputProps { const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null; -/** - * Detects if a keyboard event is part of IME composition. - * Uses both isComposing and keyCode === 229 (MDN recommended). - * WebKit may fire compositionend before keydown, causing isComposing to be false - * while keyCode remains 229, so both checks are needed. - */ -const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => { - return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229; +type PermissionAction = 'allow' | 'ask' | 'deny'; +type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; + +const asPermissionRuleset = (value: unknown): PermissionRule[] | null => { + if (!Array.isArray(value)) { + return null; + } + const rules: PermissionRule[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue; + } + const candidate = entry as Partial; + if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') { + continue; + } + if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') { + continue; + } + rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action }); + } + return rules; +}; + +const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => { + const rules = asPermissionRuleset(ruleset); + if (!rules || rules.length === 0) { + return undefined; + } + + for (let i = rules.length - 1; i >= 0; i -= 1) { + const rule = rules[i]; + if (rule.permission === permission && rule.pattern === '*') { + return rule.action; + } + } + + for (let i = rules.length - 1; i >= 0; i -= 1) { + const rule = rules[i]; + if (rule.permission === '*' && rule.pattern === '*') { + return rule.action; + } + } + + return undefined; +}; + +const buildPermissionActionMap = (ruleset: unknown, permission: string): Record | undefined => { + const rules = asPermissionRuleset(ruleset); + if (!rules || rules.length === 0) { + return undefined; + } + + const map: Record = {}; + for (const rule of rules) { + if (rule.permission !== permission) { + continue; + } + map[rule.pattern] = rule.action; + } + + return Object.keys(map).length > 0 ? map : undefined; }; export const ChatInput: React.FC = ({ onOpenSettings, scrollToBottom }) => { @@ -184,19 +239,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [agents, currentAgentName]); const agentDefaultEditMode = React.useMemo(() => { - const agentPermissionRaw = currentAgent?.permission?.edit; - let defaultMode: EditPermissionMode = 'ask'; - - if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') { - defaultMode = agentPermissionRaw; + if (!currentAgent) { + return 'deny'; } - const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false; - if (!currentAgent || !editToolConfigured) { - defaultMode = 'deny'; - } - - return defaultMode; + const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask'; + return action; }, [currentAgent]); const sessionAgentEditOverride = useSessionStore( @@ -209,8 +257,20 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [currentSessionId, currentAgentName]) ); - const agentWebfetchPermission = currentAgent?.permission?.webfetch; - const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined; + const agentWebfetchPermission = React.useMemo(() => { + if (!currentAgent) { + return undefined; + } + return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch'); + }, [currentAgent]); + + const agentBashPermission = React.useMemo(() => { + if (!currentAgent) { + return undefined; + } + const map = buildPermissionActionMap(currentAgent.permission, 'bash'); + return map ? (map as BashPermissionSetting) : undefined; + }, [currentAgent]); const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({ agentDefaultEditMode, @@ -486,8 +546,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]); const handleKeyDown = (e: React.KeyboardEvent) => { - // Early return during IME composition to prevent interference with autocomplete - // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown + // Early return during IME composition to prevent interference with autocomplete. + // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown. if (isIMECompositionEvent(e)) return; if (showCommandAutocomplete && commandRef.current) { @@ -521,7 +581,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } // Handle Enter/Ctrl+Enter based on queue mode - if (e.key === 'Enter' && !e.shiftKey && !isMobile && !isIMECompositionEvent(e)) { + if (e.key === 'Enter' && !e.shiftKey && !isMobile) { e.preventDefault(); const isCtrlEnter = e.ctrlKey || e.metaKey; diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 6cbbebe1..7c08bbbd 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -3,14 +3,14 @@ import type { Message, Part } from '@opencode-ai/sdk/v2'; import ChatMessage from './ChatMessage'; import { PermissionCard } from './PermissionCard'; -import type { Permission } from '@/types/permission'; +import type { PermissionRequest } from '@/types/permission'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager'; import { filterSyntheticParts } from '@/lib/messages/synthetic'; import { useTurnGrouping } from './hooks/useTurnGrouping'; interface MessageListProps { messages: { info: Message; parts: Part[] }[]; - permissions: Permission[]; + permissions: PermissionRequest[]; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; hasMoreAbove: boolean; diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index ef5b6561..e38d42a0 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -55,6 +55,70 @@ type ProviderModel = Record & { id?: string; name?: string }; const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null; +type PermissionAction = 'allow' | 'ask' | 'deny'; +type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; + +const asPermissionRuleset = (value: unknown): PermissionRule[] | null => { + if (!Array.isArray(value)) { + return null; + } + const rules: PermissionRule[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue; + } + const candidate = entry as Partial; + if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') { + continue; + } + if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') { + continue; + } + rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action }); + } + return rules; +}; + +const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => { + const rules = asPermissionRuleset(ruleset); + if (!rules || rules.length === 0) { + return undefined; + } + + for (let i = rules.length - 1; i >= 0; i -= 1) { + const rule = rules[i]; + if (rule.permission === permission && rule.pattern === '*') { + return rule.action; + } + } + + for (let i = rules.length - 1; i >= 0; i -= 1) { + const rule = rules[i]; + if (rule.permission === '*' && rule.pattern === '*') { + return rule.action; + } + } + + return undefined; +}; + +const buildPermissionActionMap = (ruleset: unknown, permission: string): Record | undefined => { + const rules = asPermissionRuleset(ruleset); + if (!rules || rules.length === 0) { + return undefined; + } + + const map: Record = {}; + for (const rule of rules) { + if (rule.permission !== permission) { + continue; + } + map[rule.pattern] = rule.action; + } + + return Object.keys(map).length > 0 ? map : undefined; +}; + interface CapabilityDefinition { key: 'tool_call' | 'reasoning'; icon: IconComponent; @@ -314,19 +378,29 @@ export const ModelControls: React.FC = ({ className }) => { }, [desktopModelQuery]); const currentAgent = getCurrentAgent?.(); - const agentPermissionRaw = currentAgent?.permission?.edit; - let agentDefaultEditMode: EditPermissionMode = 'ask'; - if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') { - agentDefaultEditMode = agentPermissionRaw; - } - const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false; - if (!currentAgent || !editToolConfigured) { - agentDefaultEditMode = 'deny'; - } + const agentDefaultEditMode = React.useMemo(() => { + if (!currentAgent) { + return 'deny'; + } + const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask'; + return action; + }, [currentAgent]); - const agentWebfetchPermission = currentAgent?.permission?.webfetch; - const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined; + const agentWebfetchPermission = React.useMemo(() => { + if (!currentAgent) { + return undefined; + } + return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch'); + }, [currentAgent]); + + const agentBashPermission = React.useMemo(() => { + if (!currentAgent) { + return undefined; + } + const map = buildPermissionActionMap(currentAgent.permission, 'bash'); + return map ? (map as BashPermissionSetting) : undefined; + }, [currentAgent]); const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({ agentDefaultEditMode, @@ -994,27 +1068,27 @@ export const ModelControls: React.FC = ({ className }) => { const renderMobileAgentTooltip = () => { if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null; - const enabledTools = Object.entries(currentAgent.tools || {}) - .filter(([, enabled]) => enabled) - .map(([tool]) => tool) - .sort(); - const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0); const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID; const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined; - const getPermissionIcon = (permission?: string) => { - const mode: EditPermissionMode = - permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask'; - return renderEditModeIcon(mode, 'h-3.5 w-3.5'); + const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => { + const rules = asPermissionRuleset(currentAgent.permission) ?? []; + const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*'); + const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask'; + + if (hasCustom) { + return { mode: 'ask', label: 'Custom' }; + } + + if (action === 'allow') return { mode: 'allow', label: 'Allow' }; + if (action === 'deny') return { mode: 'deny', label: 'Deny' }; + return { mode: 'ask', label: 'Ask' }; }; - const getPermissionLabel = (permission?: string) => { - if (permission === 'full') return 'Full'; - if (permission === 'allow') return 'Allow'; - if (permission === 'deny') return 'Deny'; - return 'Ask'; - }; + const editPermissionSummary = summarizePermission('edit'); + const bashPermissionSummary = summarizePermission('bash'); + const webfetchPermissionSummary = summarizePermission('webfetch'); return ( = ({ className }) => { )} - {} -
-
Tools
- {enabledTools.length > 0 ? ( -
- {enabledTools.map((tool) => ( - - {tool} - - ))} -
- ) : ( -
All enabled
- )} -
{}
@@ -1092,27 +1148,27 @@ export const ModelControls: React.FC = ({ className }) => {
Edit
- {getPermissionIcon(currentAgent.permission?.edit)} + {renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')} - {getPermissionLabel(currentAgent.permission?.edit)} + {editPermissionSummary.label}
Bash
- {getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)} + {renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')} - {getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)} + {bashPermissionSummary.label}
WebFetch
- {getPermissionIcon(currentAgent.permission?.webfetch)} + {renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')} - {getPermissionLabel(currentAgent.permission?.webfetch)} + {webfetchPermissionSummary.label}
@@ -1983,27 +2039,27 @@ export const ModelControls: React.FC = ({ className }) => { ); } - const enabledTools = Object.entries(currentAgent.tools || {}) - .filter(([, enabled]) => enabled) - .map(([tool]) => tool) - .sort(); - const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0); const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID; const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined; - const getPermissionIcon = (permission?: string) => { - const mode: EditPermissionMode = - permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask'; - return renderEditModeIcon(mode, 'h-3.5 w-3.5'); + const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => { + const rules = asPermissionRuleset(currentAgent.permission) ?? []; + const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*'); + const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask'; + + if (hasCustom) { + return { mode: 'ask', label: 'Custom' }; + } + + if (action === 'allow') return { mode: 'allow', label: 'Allow' }; + if (action === 'deny') return { mode: 'deny', label: 'Deny' }; + return { mode: 'ask', label: 'Ask' }; }; - const getPermissionLabel = (permission?: string) => { - if (permission === 'full') return 'Full'; - if (permission === 'allow') return 'Allow'; - if (permission === 'deny') return 'Deny'; - return 'Ask'; - }; + const editPermissionSummary = summarizePermission('edit'); + const bashPermissionSummary = summarizePermission('bash'); + const webfetchPermissionSummary = summarizePermission('webfetch'); return ( @@ -2053,50 +2109,33 @@ export const ModelControls: React.FC = ({ className }) => {
)} -
- Tools - {enabledTools.length > 0 ? ( -
- {enabledTools.map((tool) => ( - - {tool} - - ))} -
- ) : ( - All enabled - )} -
Permissions
Edit
- {getPermissionIcon(currentAgent.permission?.edit)} + {renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')} - {getPermissionLabel(currentAgent.permission?.edit)} + {editPermissionSummary.label}
Bash
- {getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)} + {renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')} - {getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)} + {bashPermissionSummary.label}
WebFetch
- {getPermissionIcon(currentAgent.permission?.webfetch)} + {renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')} - {getPermissionLabel(currentAgent.permission?.webfetch)} + {webfetchPermissionSummary.label}
diff --git a/packages/ui/src/components/chat/PermissionCard.tsx b/packages/ui/src/components/chat/PermissionCard.tsx index 5e9cefce..1638160a 100644 --- a/packages/ui/src/components/chat/PermissionCard.tsx +++ b/packages/ui/src/components/chat/PermissionCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; -import type { Permission, PermissionResponse } from '@/types/permission'; +import type { PermissionRequest, PermissionResponse } from '@/types/permission'; import { useSessionStore } from '@/stores/useSessionStore'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -10,7 +10,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { DiffPreview, WritePreview } from './DiffPreview'; interface PermissionCardProps { - permission: Permission; + permission: PermissionRequest; onResponse?: (response: 'once' | 'always' | 'reject') => void; } @@ -82,7 +82,7 @@ export const PermissionCard: React.FC = ({ return null; } - const toolName = permission.type || 'Unknown Tool'; + const toolName = permission.permission || 'unknown'; const tool = toolName.toLowerCase(); const getMeta = (key: string, fallback: string = ''): string => { @@ -106,9 +106,7 @@ export const PermissionCard: React.FC = ({ const description = getMeta('description'); const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path'); const timeout = getMetaNum('timeout'); - - const commandInTitle = permission.title === command; - + return ( <> {description && ( @@ -125,7 +123,7 @@ export const PermissionCard: React.FC = ({
)} {} - {command && !commandInTitle && ( + {command && (
= ({ {}
- {/* Show patterns being requested */} - {(permission.patterns as string[]) && (permission.patterns as string[]).length > 0 && ( + {permission.patterns.length > 0 && (
Patterns:
- {(permission.patterns as string[]).join(", ")} + {permission.patterns.join(", ")}
)} - {!((permission.patterns as string[]) && (permission.patterns as string[]).length > 0) && - (permission.pattern as string | string[]) && -
-
Pattern:
- - {Array.isArray(permission.pattern) ? permission.pattern.join(", ") : permission.pattern} - -
- } - - {(() => { - - let primaryContent = ''; - let primaryLanguage = 'text'; - let shouldHighlight = false; - - if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') { - primaryContent = getMeta('command') || getMeta('cmd') || getMeta('script'); - primaryLanguage = 'bash'; - shouldHighlight = true; - } - - else if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool' || tool === 'write' || tool === 'create' || tool === 'file_write') { - primaryContent = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath'); - shouldHighlight = false; - } - - else if (tool === 'webfetch' || tool === 'fetch') { - primaryContent = getMeta('url') || getMeta('uri') || getMeta('endpoint'); - shouldHighlight = false; - } - - const titleMatchesContent = permission.title === primaryContent; - - if (titleMatchesContent && primaryContent && shouldHighlight) { - return ( -
- - {primaryContent} - -
- ); - } - - if (titleMatchesContent && primaryContent && !shouldHighlight) { - return ( -
- - {primaryContent} - -
- ); - } - - if (permission.title) { - return ( -
- {permission.title} -
- ); - } - - return null; - })()} - - {} {renderToolContent()}
@@ -460,7 +363,7 @@ export const PermissionCard: React.FC = ({ Allow Once - {(permission.always as string[]) && (permission.always as string[]).length > 0 ? ( + {permission.always.length > 0 ? ( - - -
-
-

- Permission for loading skills -

- - - - - -
-

Allow: Load skills without confirmation

-

Ask: Prompt for confirmation before loading skills

-

Deny: Block all skill loading

-
-
-
-
- +
+ + + +
+ -
- -
- - - -
-
-

- Permission for repeated identical tool calls -

- - - - - -
-

Allow: Continue without confirmation

-

Ask: Prompt when a doom loop is detected

-

Deny: Block repeated tool calls

-
-
-
-
-
-
- -
- - -
-
+ + {overrides.length === 0 ? (

- Permission for file access outside project + {defaultOverride === 'default' + ? 'No overrides configured. Everything follows OpenCode defaults.' + : `No overrides configured. All permissions default to "${defaultOverride}" for this agent.`}

- - - - - -
-

Allow: Access external paths without confirmation

-

Ask: Prompt before accessing external paths

-

Deny: Block external directory access

-
-
-
-
+ ) : ( +
+ {overrides.map(([permissionName]) => { + const wildcardAction = getOverrideWildcardAction(permissionName); + const customPatternCount = getCustomPatternCount(permissionName); + const label = formatPermissionLabel(permissionName); + + return ( +
+
+ + + +
+ +
+ + + +
+ +
+ ); + })} +
+ )}
diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index 42ad9c3a..f178f499 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -18,7 +18,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react'; -import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope } from '@/stores/useAgentsStore'; +import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; @@ -30,6 +30,103 @@ interface AgentsSidebarProps { onItemSelect?: () => void; } +type PermissionAction = 'allow' | 'ask' | 'deny'; +type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; + +type PermissionConfigValue = PermissionAction | Record; + +// OpenCode's built-in defaults for permissions that differ from "allow" +const getOpenCodeDefaultActionForPermission = (permissionName: string): PermissionAction => { + if (permissionName === 'doom_loop' || permissionName === 'external_directory') { + return 'ask'; + } + return 'allow'; +}; + +const toPermissionRuleset = (ruleset: unknown): PermissionRule[] => { + if (!Array.isArray(ruleset)) { + return []; + } + + const parsed: PermissionRule[] = []; + for (const entry of ruleset) { + if (!entry || typeof entry !== 'object') { + continue; + } + const candidate = entry as Partial; + if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') { + continue; + } + if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') { + continue; + } + parsed.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action }); + } + + return parsed; +}; + +const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] => { + const parsed = toPermissionRuleset(ruleset); + if (parsed.length === 0) { + return undefined; + } + + const byPermission: Record> = {}; + for (const rule of parsed) { + if (!rule.permission) { + continue; + } + (byPermission[rule.permission] ||= {})[rule.pattern] = rule.action; + } + + // Get the global default (wildcard * with pattern *) + const globalDefault = byPermission['*']?.['*']; + + const permissionNames = Object.keys(byPermission); + if ( + permissionNames.length === 1 && + permissionNames[0] === '*' && + Object.keys(byPermission['*'] || {}).length === 1 && + byPermission['*']?.['*'] + ) { + return byPermission['*']['*']; + } + + const result: Record = {}; + for (const permissionName of permissionNames) { + const map = byPermission[permissionName]; + const patterns = Object.keys(map); + + // For wildcard-only entries, check if they're redundant + if (patterns.length === 1 && patterns[0] === '*' && permissionName !== '*') { + const action = map['*']; + const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName); + + // Skip if this permission is redundant (matches effective default) + if (globalDefault) { + if (action === globalDefault) continue; + } else { + if (action === opencodeDefault) continue; + } + + result[permissionName] = action; + } else if (permissionName === '*') { + // Include global default + if (patterns.length === 1 && patterns[0] === '*') { + result[permissionName] = map['*']; + } else { + result[permissionName] = map; + } + } else { + // Non-wildcard patterns - include as-is + result[permissionName] = map; + } + } + + return Object.keys(result).length > 0 ? (result as AgentDraft['permission']) : undefined; +}; + export const AgentsSidebar: React.FC = ({ onItemSelect }) => { const [renameDialogAgent, setRenameDialogAgent] = React.useState(null); const [renameNewName, setRenameNewName] = React.useState(''); @@ -132,12 +229,9 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => // Set draft with prefilled values from source agent const extAgent = agent as Agent & { scope?: AgentScope }; - // Convert model object to string if needed (SDK type vs API type difference) - const modelStr = typeof agent.model === 'string' - ? agent.model - : agent.model?.providerID && agent.model?.modelID - ? `${agent.model.providerID}/${agent.model.modelID}` - : undefined; + const modelStr = agent.model?.providerID && agent.model?.modelID + ? `${agent.model.providerID}/${agent.model.modelID}` + : null; const draftAgent = agent as Agent & { disable?: boolean }; setAgentDraft({ name: newName, @@ -148,8 +242,7 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => top_p: agent.topP, prompt: agent.prompt, mode: agent.mode, - tools: agent.tools, - permission: agent.permission, + permission: rulesetToPermissionConfig(agent.permission), disable: draftAgent.disable, }); setSelectedAgent(newName); @@ -185,12 +278,9 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => } // Create new agent with new name and all existing config - // Convert model object to string if needed (SDK type vs API type difference) - const renameModelStr = typeof renameDialogAgent.model === 'string' - ? renameDialogAgent.model - : renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID - ? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}` - : undefined; + const renameModelStr = renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID + ? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}` + : null; const renameExt = renameDialogAgent as Agent & { scope?: AgentScope; disable?: boolean }; const success = await createAgent({ name: sanitizedName, @@ -200,8 +290,7 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => top_p: renameDialogAgent.topP, prompt: renameDialogAgent.prompt, mode: renameDialogAgent.mode, - tools: renameDialogAgent.tools, - permission: renameDialogAgent.permission, + permission: rulesetToPermissionConfig(renameDialogAgent.permission), disable: renameExt.disable, scope: renameExt.scope, }); diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 0c7921a4..ac201cc9 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -126,7 +126,6 @@ export const ProvidersPage: React.FC = () => { const providers = useConfigStore((state) => state.providers); const selectedProviderId = useConfigStore((state) => state.selectedProviderId); const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); - const loadProviders = useConfigStore((state) => state.loadProviders); const getModelMetadata = useConfigStore((state) => state.getModelMetadata); const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState>({}); @@ -275,8 +274,7 @@ export const ProvidersPage: React.FC = () => { toast.success('API key saved'); setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' })); - await reloadOpenCodeConfiguration(); - await loadProviders(); + await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); setSelectedProvider(providerId); } catch (error) { console.error('Failed to save API key:', error); @@ -375,8 +373,7 @@ export const ProvidersPage: React.FC = () => { toast.success('OAuth connection completed'); setOauthCodes((prev) => ({ ...prev, [codeKey]: '' })); setPendingOAuth(null); - await reloadOpenCodeConfiguration(); - await loadProviders(); + await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); setSelectedProvider(providerId); } catch (error) { console.error('Failed to complete OAuth flow:', error); @@ -423,8 +420,7 @@ export const ProvidersPage: React.FC = () => { } toast.success('Provider disconnected'); - await reloadOpenCodeConfiguration(); - await loadProviders(); + await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); } catch (error) { console.error('Failed to disconnect provider:', error); toast.error('Failed to disconnect provider'); diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 62e6498e..7db1b6a1 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DirectoryTree } from './DirectoryTree'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { cn, formatPathForDisplay } from '@/lib/utils'; import { toast } from 'sonner'; @@ -33,7 +34,8 @@ export const DirectoryExplorerDialog: React.FC = ( open, onOpenChange, }) => { - const { currentDirectory, homeDirectory, setDirectory, isHomeReady } = useDirectoryStore(); + const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore(); + const { addProject, getActiveProject } = useProjectsStore(); const [pendingPath, setPendingPath] = React.useState(null); const [pathInputValue, setPathInputValue] = React.useState(''); const [hasUserSelection, setHasUserSelection] = React.useState(false); @@ -67,12 +69,13 @@ export const DirectoryExplorerDialog: React.FC = ( if (open) { setHasUserSelection(false); setIsConfirming(false); - // Initialize with current directory - const initialPath = currentDirectory || homeDirectory || ''; + // Initialize with active project or current directory + const activeProject = getActiveProject(); + const initialPath = activeProject?.path || currentDirectory || homeDirectory || ''; setPendingPath(initialPath); setPathInputValue(formatPath(initialPath)); } - }, [open, currentDirectory, homeDirectory, formatPath]); + }, [open, currentDirectory, homeDirectory, formatPath, getActiveProject]); // Set initial pending path to home when ready (only if not yet selected) React.useEffect(() => { @@ -104,13 +107,10 @@ export const DirectoryExplorerDialog: React.FC = ( if (!targetPath || isConfirming) { return; } - if (targetPath === currentDirectory) { - handleClose(); - return; - } setIsConfirming(true); try { let resolvedPath = targetPath; + let projectId: string | undefined; if (isDesktop) { const accessResult = await requestAccess(targetPath); @@ -121,6 +121,7 @@ export const DirectoryExplorerDialog: React.FC = ( return; } resolvedPath = accessResult.path ?? targetPath; + projectId = accessResult.projectId; const startResult = await startAccessing(resolvedPath); if (!startResult.success) { @@ -131,7 +132,14 @@ export const DirectoryExplorerDialog: React.FC = ( } } - setDirectory(resolvedPath); + const added = addProject(resolvedPath, { id: projectId }); + if (!added) { + toast.error('Failed to add project', { + description: 'Please select a valid directory path.', + }); + return; + } + handleClose(); } catch (error) { toast.error('Failed to select directory', { @@ -141,11 +149,10 @@ export const DirectoryExplorerDialog: React.FC = ( setIsConfirming(false); } }, [ - currentDirectory, + addProject, handleClose, isDesktop, requestAccess, - setDirectory, startAccessing, isConfirming, ]); @@ -200,9 +207,9 @@ export const DirectoryExplorerDialog: React.FC = ( const dialogHeader = ( - Select project directory + Add project directory - Choose the working directory for sessions and OpenCode operations. + Choose a folder to add as a project. ); @@ -304,7 +311,7 @@ export const DirectoryExplorerDialog: React.FC = ( disabled={isConfirming || !hasUserSelection || (!pendingPath && !pathInputValue.trim())} className="flex-1 sm:flex-none sm:w-auto sm:min-w-[140px]" > - {isConfirming ? 'Applying...' : 'Open Directory'} + {isConfirming ? 'Adding...' : 'Add Project'} ); @@ -314,7 +321,7 @@ export const DirectoryExplorerDialog: React.FC = ( onOpenChange(false)} - title="Select project directory" + title="Add project directory" className="max-w-full" contentMaxHeightClassName="max-h-[min(70vh,520px)] h-[min(70vh,520px)]" footer={
{renderActionButtons()}
} diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index f7923e0a..eb2d36b1 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -38,6 +38,9 @@ import { import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; +import { isDesktopRuntime } from '@/lib/desktop'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; @@ -122,6 +125,7 @@ export const SessionDialogs: React.FC = () => { const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false); const [isGitRepository, setIsGitRepository] = React.useState(null); const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false); + const [worktreeManagerProjectId, setWorktreeManagerProjectId] = React.useState(null); const ensuredIgnoreDirectories = React.useRef>(new Set()); const [deleteDialog, setDeleteDialog] = React.useState(null); const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState>([]); @@ -140,13 +144,22 @@ export const SessionDialogs: React.FC = () => { getWorktreeMetadata, isLoading, } = useSessionStore(); - const { currentDirectory, homeDirectory, hasPersistedDirectory, isHomeReady } = useDirectoryStore(); + const { currentDirectory, homeDirectory, isHomeReady, setDirectory } = useDirectoryStore(); + const { projects, addProject, activeProjectId } = useProjectsStore(); + const { requestAccess, startAccessing } = useFileSystemAccess(); const { agents } = useConfigStore(); const { isSessionCreateDialogOpen, setSessionCreateDialogOpen } = useUIStore(); const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const useMobileOverlay = isMobile || isTablet || hasTouchInput; - const projectDirectory = React.useMemo(() => normalizeProjectDirectory(currentDirectory), [currentDirectory]); + const projectDirectory = React.useMemo(() => { + const targetProjectId = worktreeManagerProjectId ?? activeProjectId; + const targetProject = targetProjectId + ? projects.find((project) => project.id === targetProjectId) ?? null + : null; + const targetPath = targetProject?.path ?? currentDirectory; + return normalizeProjectDirectory(targetPath); + }, [activeProjectId, currentDirectory, projects, worktreeManagerProjectId]); const sanitizedNewBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]); const worktreeTargetBranch = React.useMemo( () => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName), @@ -213,15 +226,75 @@ export const SessionDialogs: React.FC = () => { loadSessions(); }, [loadSessions, currentDirectory]); + const projectsKey = React.useMemo( + () => projects.map((project) => `${project.id}:${project.path}`).join('|'), + [projects], + ); + const lastProjectsKeyRef = React.useRef(projectsKey); + React.useEffect(() => { - if (!hasShownInitialDirectoryPrompt && isHomeReady && !hasPersistedDirectory) { - setIsDirectoryDialogOpen(true); - setHasShownInitialDirectoryPrompt(true); + if (projectsKey === lastProjectsKeyRef.current) { + return; } - }, [hasPersistedDirectory, hasShownInitialDirectoryPrompt, isHomeReady]); + + lastProjectsKeyRef.current = projectsKey; + loadSessions(); + }, [loadSessions, projectsKey]); + + React.useEffect(() => { + if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) { + return; + } + + setHasShownInitialDirectoryPrompt(true); + + if (isDesktopRuntime()) { + requestAccess('') + .then(async (result) => { + if (!result.success || !result.path) { + if (result.error && result.error !== 'Directory selection cancelled') { + toast.error('Failed to select directory', { + description: result.error, + }); + } + return; + } + + const accessResult = await startAccessing(result.path); + if (!accessResult.success) { + toast.error('Failed to open directory', { + description: accessResult.error || 'Desktop could not grant file access.', + }); + return; + } + + const added = addProject(result.path, { id: result.projectId }); + if (!added) { + toast.error('Failed to add project', { + description: 'Please select a valid directory path.', + }); + } + }) + .catch((error) => { + console.error('Desktop: Error selecting directory:', error); + toast.error('Failed to select directory'); + }); + return; + } + + setIsDirectoryDialogOpen(true); + }, [ + addProject, + hasShownInitialDirectoryPrompt, + isHomeReady, + projects.length, + requestAccess, + startAccessing, + ]); React.useEffect(() => { if (!isSessionCreateDialogOpen) { + setWorktreeManagerProjectId(null); setWorktreeCreateMode('new'); setBranchName(''); setExistingWorktreeBranch(''); @@ -372,7 +445,9 @@ export const SessionDialogs: React.FC = () => { }, []); React.useEffect(() => { - return sessionEvents.onCreateRequest(() => { + return sessionEvents.onCreateRequest((request) => { + const projectId = typeof request?.projectId === 'string' && request.projectId.trim() ? request.projectId : null; + setWorktreeManagerProjectId(projectId); setWorktreeCreateMode('new'); setBranchName(''); setExistingWorktreeBranch(''); @@ -555,6 +630,9 @@ export const SessionDialogs: React.FC = () => { setSessionDirectory(session.id, metadata.path); setWorktreeMetadata(session.id, createdMetadata); + // Ensure directory-scoped caches and session lists include the new worktree. + setDirectory(metadata.path, { showOverlay: false }); + await refreshWorktrees(); setBranchName(''); setExistingWorktreeBranch(''); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 068380ed..d0b9ae83 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1,13 +1,40 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from 'sonner'; +import { + DndContext, + DragOverlay, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { RiAddLine, RiArrowDownSLine, @@ -17,26 +44,27 @@ import { RiDeleteBinLine, RiErrorWarningLine, RiFileCopyLine, - RiFolder6Line, RiGitRepositoryLine, RiLinkUnlinkM, RiMore2Line, RiPencilAiLine, RiShare2Line, + RiShieldLine, } from '@remixicon/react'; import { sessionEvents } from '@/lib/sessionEvents'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import type { WorktreeMetadata } from '@/types/worktree'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { getSafeStorage } from '@/stores/utils/safeStorage'; -const WORKTREE_ROOT = '.openchamber'; const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; +const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; const formatDateLabel = (value: string | number) => { @@ -72,21 +100,11 @@ const normalizePath = (value?: string | null) => { return normalized.length === 0 ? '/' : normalized; }; -const deriveProjectRoot = (directory: string | null, metadata: Map): string | null => { - const normalized = normalizePath(directory); - const firstMetadata = Array.from(metadata.values())[0]; - if (firstMetadata?.projectDirectory) { - return normalizePath(firstMetadata.projectDirectory); - } - if (!normalized) { - return null; - } - const marker = `/${WORKTREE_ROOT}`; - const markerIndex = normalized.indexOf(marker); - if (markerIndex > 0) { - return normalized.slice(0, markerIndex); - } - return normalized; +// Format project label: kebab-case/snake_case → Title Case +const formatProjectLabel = (label: string): string => { + return label + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); }; type SessionNode = { @@ -104,6 +122,204 @@ type SessionGroup = { sessions: SessionNode[]; }; +interface SortableProjectItemProps { + id: string; + projectLabel: string; + projectDescription: string; + isCollapsed: boolean; + isActiveProject: boolean; + isRepo: boolean; + isHovered: boolean; + isDesktopRuntime: boolean; + isStuck: boolean; + hideDirectoryControls: boolean; + mobileVariant: boolean; + onToggle: () => void; + onHoverChange: (hovered: boolean) => void; + onOpenWorktreeManager: () => void; + onOpenMultiRunLauncher: () => void; + onClose: () => void; + sentinelRef: (el: HTMLDivElement | null) => void; + children?: React.ReactNode; +} + +const SortableProjectItem: React.FC = ({ + id, + projectLabel, + projectDescription, + isCollapsed, + isActiveProject, + isRepo, + isHovered, + isDesktopRuntime, + isStuck, + hideDirectoryControls, + mobileVariant, + onToggle, + onHoverChange, + onOpenWorktreeManager, + onOpenMultiRunLauncher, + onClose, + sentinelRef, + children, +}) => { + const { + attributes, + listeners, + setNodeRef, + isDragging, + } = useSortable({ id }); + + return ( +
+ {/* Sentinel for sticky detection */} + {isDesktopRuntime && ( + + ); +}; + +// Drag overlay component - shows only the header during drag +interface ProjectDragOverlayProps { + projectLabel: string; + isActiveProject: boolean; + isCollapsed: boolean; +} + +const ProjectDragOverlay: React.FC = ({ + projectLabel, + isActiveProject, + isCollapsed, +}) => { + return ( +
+
+ + {projectLabel} + + + {isCollapsed ? ( + + ) : ( + + )} + +
+
+ ); +}; + interface SessionSidebarProps { mobileVariant?: boolean; onSessionSelected?: (sessionId: string) => void; @@ -130,21 +346,40 @@ export const SessionSidebar: React.FC = ({ const checkingDirectories = React.useRef>(new Set()); const safeStorage = React.useMemo(() => getSafeStorage(), []); const [collapsedGroups, setCollapsedGroups] = React.useState>(new Set()); - const [isGitRepo, setIsGitRepo] = React.useState(null); + const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); + const [pendingProjectClose, setPendingProjectClose] = React.useState<{ + id: string; + label: string; + } | null>(null); + const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); const [hoveredGroupId, setHoveredGroupId] = React.useState(null); + const [hoveredProjectId, setHoveredProjectId] = React.useState(null); + const [activeDragId, setActiveDragId] = React.useState(null); const [stuckHeaders, setStuckHeaders] = React.useState>(new Set()); + const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); const headerSentinelRefs = React.useRef>(new Map()); + const projectHeaderSentinelRefs = React.useRef>(new Map()); + const ignoreIntersectionUntil = React.useRef(0); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const setDirectory = useDirectoryStore((state) => state.setDirectory); + const projects = useProjectsStore((state) => state.projects); + const activeProjectId = useProjectsStore((state) => state.activeProjectId); + const addProject = useProjectsStore((state) => state.addProject); + const removeProject = useProjectsStore((state) => state.removeProject); + const setActiveProject = useProjectsStore((state) => state.setActiveProject); + const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); + const reorderProjects = useProjectsStore((state) => state.reorderProjects); + const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher); - const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); + const sessions = useSessionStore((state) => state.sessions); + const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory); const currentSessionId = useSessionStore((state) => state.currentSessionId); const setCurrentSession = useSessionStore((state) => state.setCurrentSession); const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle); @@ -152,8 +387,10 @@ export const SessionSidebar: React.FC = ({ const unshareSession = useSessionStore((state) => state.unshareSession); const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState); const sessionActivityPhase = useSessionStore((state) => state.sessionActivityPhase); + const permissions = useSessionStore((state) => state.permissions); const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); - const availableWorktrees = useSessionStore((state) => state.availableWorktrees); + const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); + const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { @@ -179,6 +416,13 @@ export const SessionSidebar: React.FC = ({ setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string'))); } } + const storedProjects = safeStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY); + if (storedProjects) { + const parsed = JSON.parse(storedProjects); + if (Array.isArray(parsed)) { + setCollapsedProjects(new Set(parsed.filter((item) => typeof item === 'string'))); + } + } } catch { /* ignored */ } }, [safeStorage]); @@ -189,36 +433,50 @@ export const SessionSidebar: React.FC = ({ setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); }, []); - const sessions = getSessionsByDirectory(currentDirectory); const sortedSessions = React.useMemo(() => { return [...sessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0)); }, [sessions]); React.useEffect(() => { - if (!currentDirectory) { - setIsGitRepo(null); - return; - } let cancelled = false; - checkIsGitRepository(currentDirectory) - .then((result) => { - if (!cancelled) { - setIsGitRepo(result); - } - }) - .catch(() => { - if (!cancelled) { - setIsGitRepo(null); - } - }); + const normalizedProjects = projects + .map((project) => ({ id: project.id, path: normalizePath(project.path) })) + .filter((project): project is { id: string; path: string } => Boolean(project.path)); + + setProjectRepoStatus(new Map()); + + if (normalizedProjects.length === 0) { + return () => { + cancelled = true; + }; + } + + normalizedProjects.forEach((project) => { + checkIsGitRepository(project.path) + .then((result) => { + if (!cancelled) { + setProjectRepoStatus((prev) => { + const next = new Map(prev); + next.set(project.id, result); + return next; + }); + } + }) + .catch(() => { + if (!cancelled) { + setProjectRepoStatus((prev) => { + const next = new Map(prev); + next.set(project.id, null); + return next; + }); + } + }); + }); + return () => { cancelled = true; }; - }, [currentDirectory]); - - const sessionMap = React.useMemo(() => { - return new Map(sortedSessions.map((session) => [session.id, session])); - }, [sortedSessions]); + }, [projects]); const parentMap = React.useMemo(() => { const map = new Map(); @@ -265,11 +523,6 @@ export const SessionSidebar: React.FC = ({ }); }, [currentSessionId, parentMap]); - const projectRoot = React.useMemo( - () => deriveProjectRoot(currentDirectory, worktreeMetadata), - [currentDirectory, worktreeMetadata], - ); - React.useEffect(() => { const directories = new Set(); sortedSessions.forEach((session) => { @@ -278,9 +531,12 @@ export const SessionSidebar: React.FC = ({ directories.add(dir); } }); - if (projectRoot) { - directories.add(projectRoot); - } + projects.forEach((project) => { + const normalized = normalizePath(project.path); + if (normalized) { + directories.add(normalized); + } + }); directories.forEach((directory) => { const known = directoryStatus.get(directory); @@ -314,7 +570,7 @@ export const SessionSidebar: React.FC = ({ checkingDirectories.current.delete(directory); }); }); - }, [sortedSessions, projectRoot, directoryStatus]); + }, [sortedSessions, projects, directoryStatus]); React.useEffect(() => { return () => { @@ -324,15 +580,6 @@ export const SessionSidebar: React.FC = ({ }; }, []); - const displayDirectory = React.useMemo( - () => formatDirectoryName(currentDirectory, homeDirectory), - [currentDirectory, homeDirectory], - ); - - const directoryTooltip = React.useMemo( - () => formatPathForDisplay(currentDirectory, homeDirectory), - [currentDirectory, homeDirectory], - ); const emptyState = (
@@ -342,11 +589,20 @@ export const SessionSidebar: React.FC = ({ ); const handleSessionSelect = React.useCallback( - (sessionId: string, disabled?: boolean) => { + (sessionId: string, sessionDirectory?: string | null, disabled?: boolean, projectId?: string | null) => { if (disabled) { return; } + if (projectId && projectId !== activeProjectId) { + // Important: avoid switching to the project root first (that can select the wrong session). + setActiveProjectIdOnly(projectId); + } + + if (sessionDirectory && sessionDirectory !== currentDirectory) { + setDirectory(sessionDirectory, { showOverlay: false }); + } + if (mobileVariant) { setActiveMainTab('chat'); setSessionSwitcherOpen(false); @@ -360,12 +616,16 @@ export const SessionSidebar: React.FC = ({ onSessionSelected?.(sessionId); }, [ + activeProjectId, allowReselect, + currentDirectory, currentSessionId, mobileVariant, onSessionSelected, setActiveMainTab, + setActiveProjectIdOnly, setCurrentSession, + setDirectory, setSessionSwitcherOpen, ], ); @@ -473,19 +733,28 @@ export const SessionSidebar: React.FC = ({ ); const handleCreateSessionInGroup = React.useCallback( - (directory: string | null) => { + (directory: string | null, projectId?: string | null) => { + if (projectId && projectId !== activeProjectId) { + setActiveProject(projectId); + } setActiveMainTab('chat'); if (mobileVariant) { setSessionSwitcherOpen(false); } openNewSessionDraft({ directoryOverride: directory ?? null }); }, - [openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen, mobileVariant], + [activeProjectId, openNewSessionDraft, setActiveMainTab, setActiveProject, setSessionSwitcherOpen, mobileVariant], ); - const handleOpenWorktreeManager = React.useCallback(() => { - sessionEvents.requestCreate({ worktreeMode: 'create' }); - }, []); + const handleOpenWorktreeManager = React.useCallback( + (projectId?: string | null) => { + if (projectId && projectId !== activeProjectId) { + setActiveProjectIdOnly(projectId); + } + sessionEvents.requestCreate({ worktreeMode: 'create', projectId: projectId ?? null }); + }, + [activeProjectId, setActiveProjectIdOnly], + ); const handleOpenDirectoryDialog = React.useCallback(() => { if (isDesktopRuntime && window.opencodeDesktop?.requestDirectoryAccess) { @@ -493,7 +762,12 @@ export const SessionSidebar: React.FC = ({ .requestDirectoryAccess('') .then((result) => { if (result.success && result.path) { - setDirectory(result.path, { showOverlay: true }); + const added = addProject(result.path, { id: result.projectId }); + if (!added) { + toast.error('Failed to add project', { + description: 'Please select a valid directory.', + }); + } } else if (result.error && result.error !== 'Directory selection cancelled') { toast.error('Failed to select directory', { description: result.error, @@ -507,7 +781,22 @@ export const SessionSidebar: React.FC = ({ } else { sessionEvents.requestDirectoryDialog(); } - }, [isDesktopRuntime, setDirectory]); + }, [addProject, isDesktopRuntime]); + + const confirmPendingProjectClose = React.useCallback(() => { + const pending = pendingProjectClose; + if (!pending) { + return; + } + + removeProject(pending.id); + setPendingProjectClose(null); + toast.success('Project closed', { description: pending.label }); + }, [pendingProjectClose, removeProject]); + + const cancelPendingProjectClose = React.useCallback(() => { + setPendingProjectClose(null); + }, []); const toggleParent = React.useCallback((sessionId: string) => { setExpandedParents((prev) => { @@ -535,113 +824,134 @@ export const SessionSidebar: React.FC = ({ [childrenMap], ); - const groupedSessions = React.useMemo(() => { - const groups = new Map(); - const normalizedProjectRoot = normalizePath(projectRoot ?? null); - const worktreeByPath = new Map(); - const existingWorktreePaths = new Set(); - availableWorktrees.forEach((meta) => { - if (meta.path) { - const normalized = normalizePath(meta.path) ?? meta.path; - existingWorktreePaths.add(normalized); - worktreeByPath.set(normalized, meta); - } - }); + const buildGroupedSessions = React.useCallback( + (projectSessions: Session[], projectRoot: string | null, availableWorktrees: WorktreeMetadata[]) => { + const groups = new Map(); + const normalizedProjectRoot = normalizePath(projectRoot ?? null); + const sortedProjectSessions = [...projectSessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0)); - const ensureGroup = (session: Session) => { - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - - const sessionWorktreeMeta = worktreeMetadata.get(session.id); - const sessionWorktreeExists = sessionWorktreeMeta?.path - ? existingWorktreePaths.has(normalizePath(sessionWorktreeMeta.path) ?? sessionWorktreeMeta.path) - : false; - const worktree = - (sessionWorktreeExists ? sessionWorktreeMeta : null) ?? - (sessionDirectory ? worktreeByPath.get(sessionDirectory) ?? null : null); - const isMain = - !worktree && - ((sessionDirectory && normalizedProjectRoot - ? sessionDirectory === normalizedProjectRoot - : !sessionDirectory && Boolean(normalizedProjectRoot))); - const key = isMain ? 'main' : worktree?.path ?? sessionDirectory ?? session.id; - const directory = worktree?.path ?? sessionDirectory ?? normalizedProjectRoot ?? null; - if (!groups.has(key)) { - const label = isMain - ? 'Main workspace' - : worktree?.label || worktree?.branch || formatDirectoryName(directory || '', homeDirectory) || 'Worktree'; - const description = worktree?.relativePath - ? formatPathForDisplay(worktree.relativePath, homeDirectory) - : directory - ? formatPathForDisplay(directory, homeDirectory) - : null; - groups.set(key, { - id: key, - label, - description, - isMain, - worktree, - directory, - sessions: [], - }); - } - return groups.get(key)!; - }; - - const roots = sortedSessions.filter((session) => { - const parentID = (session as Session & { parentID?: string | null }).parentID; - if (!parentID) { - return true; - } - return !sessionMap.has(parentID); - }); - - roots.forEach((session) => { - const group = ensureGroup(session); - const node = buildNode(session); - group.sessions.push(node); - }); - - if (!groups.has('main')) { - groups.set('main', { - id: 'main', - label: 'Main workspace', - description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null, - isMain: true, - worktree: null, - directory: normalizedProjectRoot, - sessions: [], + const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session])); + const childrenMap = new Map(); + sortedProjectSessions.forEach((session) => { + const parentID = (session as Session & { parentID?: string | null }).parentID; + if (!parentID) { + return; + } + const collection = childrenMap.get(parentID) ?? []; + collection.push(session); + childrenMap.set(parentID, collection); }); - } + childrenMap.forEach((list) => list.sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0))); - worktreeByPath.forEach((meta, path) => { - const key = meta.path; - if (!groups.has(key)) { - groups.set(key, { - id: key, - label: meta.label || meta.branch || formatDirectoryName(path, homeDirectory) || 'Worktree', - description: meta.relativePath - ? formatPathForDisplay(meta.relativePath, homeDirectory) - : formatPathForDisplay(path, homeDirectory), - isMain: false, - worktree: meta, - directory: path, + const buildProjectNode = (session: Session): SessionNode => { + const children = childrenMap.get(session.id) ?? []; + return { + session, + children: children.map((child) => buildProjectNode(child)), + }; + }; + + const worktreeByPath = new Map(); + availableWorktrees.forEach((meta) => { + if (meta.path) { + const normalized = normalizePath(meta.path) ?? meta.path; + worktreeByPath.set(normalized, meta); + } + }); + + const ensureGroup = (session: Session) => { + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + + const sessionWorktreeMeta = worktreeMetadata.get(session.id) ?? null; + const worktree = + sessionWorktreeMeta ?? + (sessionDirectory ? worktreeByPath.get(sessionDirectory) ?? null : null); + const isMain = + !worktree && + ((sessionDirectory && normalizedProjectRoot + ? sessionDirectory === normalizedProjectRoot + : !sessionDirectory && Boolean(normalizedProjectRoot))); + const key = isMain ? 'main' : worktree?.path ?? sessionDirectory ?? session.id; + const directory = worktree?.path ?? sessionDirectory ?? normalizedProjectRoot ?? null; + if (!groups.has(key)) { + const label = isMain + ? 'Main workspace' + : worktree?.label || worktree?.branch || formatDirectoryName(directory || '', homeDirectory) || 'Worktree'; + const description = worktree?.relativePath + ? formatPathForDisplay(worktree.relativePath, homeDirectory) + : directory + ? formatPathForDisplay(directory, homeDirectory) + : null; + groups.set(key, { + id: key, + label, + description, + isMain, + worktree, + directory, + sessions: [], + }); + } + return groups.get(key)!; + }; + + const roots = sortedProjectSessions.filter((session) => { + const parentID = (session as Session & { parentID?: string | null }).parentID; + if (!parentID) { + return true; + } + return !sessionMap.has(parentID); + }); + + roots.forEach((session) => { + const group = ensureGroup(session); + const node = buildProjectNode(session); + group.sessions.push(node); + }); + + if (!groups.has('main')) { + groups.set('main', { + id: 'main', + label: 'Main workspace', + description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null, + isMain: true, + worktree: null, + directory: normalizedProjectRoot, sessions: [], }); } - }); - groups.forEach((group) => { - group.sessions.sort((a, b) => (b.session.time?.created || 0) - (a.session.time?.created || 0)); - }); + worktreeByPath.forEach((meta, path) => { + const key = meta.path; + if (!groups.has(key)) { + groups.set(key, { + id: key, + label: meta.label || meta.branch || formatDirectoryName(path, homeDirectory) || 'Worktree', + description: meta.relativePath + ? formatPathForDisplay(meta.relativePath, homeDirectory) + : formatPathForDisplay(path, homeDirectory), + isMain: false, + worktree: meta, + directory: path, + sessions: [], + }); + } + }); - return Array.from(groups.values()).sort((a, b) => { - if (a.isMain !== b.isMain) { - return a.isMain ? -1 : 1; - } - return (a.label || '').localeCompare(b.label || ''); - }); - }, [sortedSessions, worktreeMetadata, availableWorktrees, projectRoot, homeDirectory, buildNode, sessionMap]); + groups.forEach((group) => { + group.sessions.sort((a, b) => (b.session.time?.created || 0) - (a.session.time?.created || 0)); + }); + + return Array.from(groups.values()).sort((a, b) => { + if (a.isMain !== b.isMain) { + return a.isMain ? -1 : 1; + } + return (a.label || '').localeCompare(b.label || ''); + }); + }, + [homeDirectory, worktreeMetadata] + ); const toggleGroup = React.useCallback((groupId: string) => { setCollapsedGroups((prev) => { @@ -670,12 +980,87 @@ export const SessionSidebar: React.FC = ({ }); }, []); + const toggleProject = React.useCallback((projectId: string) => { + // Ignore intersection events for a short period after toggling + ignoreIntersectionUntil.current = Date.now() + 150; + setCollapsedProjects((prev) => { + const next = new Set(prev); + if (next.has(projectId)) { + next.delete(projectId); + } else { + next.add(projectId); + } + try { + safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next))); + } catch { /* ignored */ } + return next; + }); + }, [safeStorage]); + + const normalizedProjects = React.useMemo(() => { + return projects + .map((project) => ({ + ...project, + normalizedPath: normalizePath(project.path), + })) + .filter((project) => Boolean(project.normalizedPath)) as Array<{ + id: string; + path: string; + label?: string; + normalizedPath: string; + }>; + }, [projects]); + + const getSessionsForProject = React.useCallback( + (project: { normalizedPath: string }) => { + const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; + const directories = [ + project.normalizedPath, + ...worktreesForProject + .map((meta) => normalizePath(meta.path) ?? meta.path) + .filter((value): value is string => Boolean(value)), + ]; + + const seen = new Set(); + const collected: Session[] = []; + + directories.forEach((directory) => { + const sessionsForDirectory = sessionsByDirectory.get(directory) ?? getSessionsByDirectory(directory); + sessionsForDirectory.forEach((session) => { + if (seen.has(session.id)) { + return; + } + seen.add(session.id); + collected.push(session); + }); + }); + + return collected; + }, + [availableWorktreesByProject, getSessionsByDirectory, sessionsByDirectory], + ); + + const projectSections = React.useMemo(() => { + return normalizedProjects.map((project) => { + const projectSessions = getSessionsForProject(project); + const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; + const groups = buildGroupedSessions(projectSessions, project.normalizedPath, worktreesForProject); + return { + project, + groups, + }; + }); + }, [normalizedProjects, getSessionsForProject, buildGroupedSessions, availableWorktreesByProject]); + // Track when sticky headers become "stuck" using sentinel elements React.useEffect(() => { if (!isDesktopRuntime) return; const observer = new IntersectionObserver( (entries) => { + // Ignore intersection events shortly after collapse/expand + if (Date.now() < ignoreIntersectionUntil.current) return; + entries.forEach((entry) => { const groupId = (entry.target as HTMLElement).dataset.groupId; if (!groupId) return; @@ -692,18 +1077,55 @@ export const SessionSidebar: React.FC = ({ }); }); }, + { threshold: 0, rootMargin: '-96px 0px 0px 0px' } + ); + + // Small delay to let DOM settle after collapse/expand + const timeoutId = setTimeout(() => { + headerSentinelRefs.current.forEach((el) => { + if (el) observer.observe(el); + }); + }, 50); + + return () => { + clearTimeout(timeoutId); + observer.disconnect(); + }; + }, [isDesktopRuntime, projectSections, collapsedProjects]); + + // Track when project sticky headers become "stuck" + React.useEffect(() => { + if (!isDesktopRuntime) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const projectId = (entry.target as HTMLElement).dataset.projectId; + if (!projectId) return; + + setStuckProjectHeaders((prev) => { + const next = new Set(prev); + if (!entry.isIntersecting) { + next.add(projectId); + } else { + next.delete(projectId); + } + return next; + }); + }); + }, { threshold: 0 } ); - headerSentinelRefs.current.forEach((el) => { + projectHeaderSentinelRefs.current.forEach((el) => { if (el) observer.observe(el); }); return () => observer.disconnect(); - }, [isDesktopRuntime, groupedSessions]); + }, [isDesktopRuntime, projectSections]); const renderSessionNode = React.useCallback( - (node: SessionNode, depth = 0, groupDirectory?: string | null): React.ReactNode => { + (node: SessionNode, depth = 0, groupDirectory?: string | null, projectId?: string | null): React.ReactNode => { const session = node.session; const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? @@ -796,6 +1218,7 @@ export const SessionSidebar: React.FC = ({ const phase = sessionActivityPhase?.get(session.id) ?? 'idle'; const isStreaming = phase === 'busy' || phase === 'cooldown'; + const pendingPermissionCount = permissions.get(session.id)?.length ?? 0; const streamingIndicator = (() => { if (!memoryState) return null; @@ -819,7 +1242,7 @@ export const SessionSidebar: React.FC = ({
{} @@ -963,7 +1397,7 @@ export const SessionSidebar: React.FC = ({
{hasChildren && isExpanded ? node.children.map((child) => - renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory), + renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId), ) : null} @@ -973,6 +1407,7 @@ export const SessionSidebar: React.FC = ({ directoryStatus, sessionMemoryState, sessionActivityPhase, + permissions, currentSessionId, expandedParents, editingId, @@ -990,6 +1425,105 @@ export const SessionSidebar: React.FC = ({ ], ); + const renderGroupSessions = React.useCallback( + (group: SessionGroup, groupKey: string, projectId?: string | null) => { + const isExpanded = expandedSessionGroups.has(groupKey); + const maxVisible = hideDirectoryControls ? 10 : 5; + const totalSessions = group.sessions.length; + const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible); + const remainingCount = totalSessions - visibleSessions.length; + + return ( + <> + {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} + {totalSessions === 0 ? ( +
+ No sessions in this workspace yet. +
+ ) : null} + {remainingCount > 0 && !isExpanded ? ( + + ) : null} + {isExpanded && totalSessions > maxVisible ? ( + + ) : null} + + ); + }, + [expandedSessionGroups, hideDirectoryControls, renderSessionNode, toggleGroupSessionLimit] + ); + + // DnD sensors for project reordering + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragStart = React.useCallback( + (event: DragStartEvent) => { + setActiveDragId(event.active.id as string); + }, + [] + ); + + const handleDragEnd = React.useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveDragId(null); + + if (!over || active.id === over.id) { + return; + } + + const oldIndex = normalizedProjects.findIndex((p) => p.id === active.id); + const newIndex = normalizedProjects.findIndex((p) => p.id === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + reorderProjects(oldIndex, newIndex); + } + }, + [normalizedProjects, reorderProjects] + ); + + const handleDragCancel = React.useCallback(() => { + setActiveDragId(null); + }, []); + + // Get the active dragging project for the overlay + const activeDragProject = React.useMemo(() => { + if (!activeDragId) return null; + const section = projectSections.find((s) => s.project.id === activeDragId); + if (!section) return null; + const project = section.project; + return { + id: project.id, + label: formatProjectLabel( + project.label?.trim() + || formatDirectoryName(project.normalizedPath, homeDirectory) + || project.normalizedPath + ), + isActive: project.id === activeProjectId, + isCollapsed: collapsedProjects.has(project.id), + }; + }, [activeDragId, projectSections, homeDirectory, activeProjectId, collapsedProjects]); + return (
= ({ )} > {!hideDirectoryControls && ( -
-
+
+
+
+

Projects

+ + {projects.length} + +
- - {isGitRepo ? ( - <> - - - - ) : null}
)} @@ -1061,157 +1560,207 @@ export const SessionSidebar: React.FC = ({ outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', mobileVariant ? '' : '')} > - {groupedSessions.length === 0 ? ( + {projectSections.length === 0 ? ( emptyState - ) : (hideDirectoryControls && groupedSessions.length === 1 && groupedSessions[0].isMain) || showOnlyMainWorkspace ? ( + ) : showOnlyMainWorkspace ? (
{(() => { - const group = groupedSessions.find(g => g.isMain) ?? groupedSessions[0]; - const maxVisible = hideDirectoryControls ? 10 : 7; - const totalSessions = group.sessions.length; - const isExpanded = expandedSessionGroups.has(group.id); - const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible); - const remainingCount = totalSessions - visibleSessions.length; - - if (totalSessions === 0) { + const activeSection = projectSections.find((section) => section.project.id === activeProjectId) ?? projectSections[0]; + if (!activeSection) { + return emptyState; + } + // VS Code sessions view typically only shows one workspace, but sessions may live in worktrees or + // canonicalized paths. Prefer the main group if it has sessions; otherwise fall back to any group + // that contains sessions so we don't show an empty list when sessions exist. + const group = + activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0) + ?? activeSection.groups.find((candidate) => candidate.sessions.length > 0) + ?? activeSection.groups.find((candidate) => candidate.isMain) + ?? activeSection.groups[0]; + if (!group) { return (
No sessions yet.
); } - - return ( - <> - {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory))} - {remainingCount > 0 && !isExpanded ? ( - - ) : null} - {isExpanded && totalSessions > maxVisible ? ( - - ) : null} - - ); + const groupKey = `${activeSection.project.id}:${group.id}`; + return renderGroupSessions(group, groupKey, activeSection.project.id); })()}
) : ( - groupedSessions.map((group) => ( -
- {/* Sentinel element to detect when header becomes stuck */} - {isDesktopRuntime && ( -
{ headerSentinelRefs.current.set(group.id, el); }} - data-group-id={group.id} - className="absolute top-0 h-px w-full pointer-events-none" - aria-hidden="true" + + p.id)} + strategy={verticalListSortingStrategy} + > + {projectSections.map((section) => { + const project = section.project; + const projectKey = project.id; + const projectLabel = formatProjectLabel( + project.label?.trim() + || formatDirectoryName(project.normalizedPath, homeDirectory) + || project.normalizedPath + ); + const projectDescription = formatPathForDisplay(project.normalizedPath, homeDirectory); + const isCollapsed = collapsedProjects.has(projectKey); + const isActiveProject = projectKey === activeProjectId; + const isRepo = projectRepoStatus.get(projectKey); + const isHovered = hoveredProjectId === projectKey; + + return ( + toggleProject(projectKey)} + onHoverChange={(hovered) => setHoveredProjectId(hovered ? projectKey : null)} + onOpenWorktreeManager={() => handleOpenWorktreeManager(projectKey)} + onOpenMultiRunLauncher={() => { + if (projectKey !== activeProjectId) { + setActiveProject(projectKey); + } + openMultiRunLauncher(); + }} + onClose={() => setPendingProjectClose({ id: projectKey, label: projectLabel })} + sentinelRef={(el) => { projectHeaderSentinelRefs.current.set(projectKey, el); }} + > + {!isCollapsed ? ( +
+ {section.groups.map((group) => { + const groupKey = `${projectKey}:${group.id}`; + return ( +
+ {isDesktopRuntime && ( +
{ headerSentinelRefs.current.set(groupKey, el); }} + data-group-id={groupKey} + className="absolute top-0 h-px w-full pointer-events-none" + aria-hidden="true" + /> + )} + + + {!collapsedGroups.has(groupKey) ? ( +
+ {renderGroupSessions(group, groupKey, projectKey)} +
+ ) : null} +
+ ); + })} +
+ ) : null} + + ); + })} + + + {activeDragProject ? ( + - )} - - - {} - {!collapsedGroups.has(group.id) ? ( -
- {(() => { - const isExpanded = expandedSessionGroups.has(group.id); - const maxVisible = hideDirectoryControls ? 10 : 7; - const totalSessions = group.sessions.length; - const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible); - const remainingCount = totalSessions - visibleSessions.length; - - return ( - <> - {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory))} - {totalSessions === 0 ? ( -
- No sessions in this worktree yet. -
- ) : null} - {remainingCount > 0 && !isExpanded ? ( - - ) : null} - {isExpanded && totalSessions > maxVisible ? ( - - ) : null} - - ); - })()} -
) : null} -
- )) + +
)} + + { + if (!open) { + setPendingProjectClose(null); + } + }} + > + + + Close project? + + This removes it from the sidebar. You can add it again later. + + + +
+ {pendingProjectClose?.label} +
+ + + + + +
+
); }; diff --git a/packages/ui/src/components/ui/ScrollableOverlay.tsx b/packages/ui/src/components/ui/ScrollableOverlay.tsx index e02532e2..7b00b51e 100644 --- a/packages/ui/src/components/ui/ScrollableOverlay.tsx +++ b/packages/ui/src/components/ui/ScrollableOverlay.tsx @@ -34,13 +34,13 @@ export const ScrollableOverlay = React.forwardRef } className={cn( - "overlay-scrollbar-target overlay-scrollbar-container", + "overlay-scrollbar-target overlay-scrollbar-container overscroll-none", fillContainer ? "flex-1 min-h-0 w-full h-full" : "flex-none w-full h-auto", disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto", className diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index fa08a973..a5f524ae 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -2,8 +2,20 @@ import React from 'react'; import { cn, getModifierLabel } from '@/lib/utils'; import { SIDEBAR_SECTIONS } from '@/constants/sidebar'; import type { SidebarSection } from '@/constants/sidebar'; -import { RiArrowLeftSLine, RiCloseLine } from '@remixicon/react'; +import { RiArrowDownSLine, RiArrowLeftSLine, RiCloseLine, RiFolderLine } from '@remixicon/react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useAgentsStore } from '@/stores/useAgentsStore'; +import { useCommandsStore } from '@/stores/useCommandsStore'; +import { useSkillsStore } from '@/stores/useSkillsStore'; +import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar'; import { AgentsPage } from '@/components/sections/agents/AgentsPage'; import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar'; @@ -99,8 +111,61 @@ export const SettingsView: React.FC = ({ onClose, forceMobile return () => observer.disconnect(); }, []); + const projects = useProjectsStore((state) => state.projects); + const activeProjectId = useProjectsStore((state) => state.activeProjectId); + const setActiveProject = useProjectsStore((state) => state.setActiveProject); + + const sortedProjects = React.useMemo(() => { + return [...projects].sort((a, b) => (a.label || a.path).localeCompare(b.label || b.path)); + }, [projects]); + + const activeProject = React.useMemo(() => { + if (sortedProjects.length === 0) { + return null; + } + return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0]; + }, [activeProjectId, sortedProjects]); + + // Format project label: kebab-case/snake_case → Title Case + const formatProjectLabel = React.useCallback((label: string): string => { + return label + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); + }, []); + + const activeProjectLabel = React.useMemo(() => { + if (!activeProject) { + return 'Project'; + } + const rawLabel = activeProject.label && activeProject.label.trim().length > 0 + ? activeProject.label + : (activeProject.path.split('/').filter(Boolean).pop() || activeProject.path); + return formatProjectLabel(rawLabel); + }, [activeProject, formatProjectLabel]); + + const showProjectSwitcher = sortedProjects.length > 0; + const showTabLabels = containerWidth === 0 || containerWidth >= TAB_LABELS_MIN_WIDTH; + React.useEffect(() => { + // Force reload when activeProject changes to ensure scopes update + if (activeTab === 'agents') { + // Small delay to allow store state to propagate if needed + setTimeout(() => void useAgentsStore.getState().loadAgents(), 0); + return; + } + + if (activeTab === 'commands') { + setTimeout(() => void useCommandsStore.getState().loadCommands(), 0); + return; + } + + if (activeTab === 'skills') { + void useSkillsStore.getState().loadSkills(); + void useSkillsCatalogStore.getState().loadCatalog(); + } + }, [activeProjectId, activeTab]); + // Update proportional width on window resize (if not manually resized) React.useEffect(() => { if (typeof window === 'undefined') return; @@ -341,26 +406,79 @@ export const SettingsView: React.FC = ({ onClose, forceMobile })}
- {onClose && ( -
- - - + ) : ( + )} - > - - - - -

Close Settings ({shortcutKey}+,)

-
-
+ + + { + if (!value) return; + setActiveProject(value); + }} + > + {sortedProjects.map((project) => { + const rawLabel = project.label?.trim() + ? project.label.trim() + : (project.path.split('/').filter(Boolean).pop() || project.path); + const label = formatProjectLabel(rawLabel); + return ( + + {label} + + ); + })} + + + + )} + + {onClose && ( + + + + + +

Close Settings ({shortcutKey}+,)

+
+
+ )}
)}
diff --git a/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx index 93e019e4..03d4594c 100644 --- a/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx @@ -3,7 +3,9 @@ import { RiGitBranchLine, RiArrowDownSLine, RiCheckLine, + RiMore2Line, } from '@remixicon/react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; @@ -11,6 +13,14 @@ import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/ import { useSessionStore } from '@/stores/useSessionStore'; import { ChatContainer } from '@/components/chat/ChatContainer'; import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, @@ -27,8 +37,10 @@ export const AgentGroupDetail: React.FC = ({ group, className, }) => { - const { selectedSessionId, selectSession } = useAgentGroupsStore(); + const { selectedSessionId, selectSession, deleteGroupWorktree, keepOnlyGroupWorktree } = useAgentGroupsStore(); const { setCurrentSession, currentSessionId } = useSessionStore(); + const [worktreeDialog, setWorktreeDialog] = React.useState(null); + const [isProcessing, setIsProcessing] = React.useState(false); // Find the currently selected session const selectedSession = React.useMemo(() => { @@ -70,6 +82,47 @@ export const AgentGroupDetail: React.FC = ({ // Check if the current OpenCode session matches the selected agent group session const isSessionSynced = selectedSession?.id === currentSessionId; + const handleRemoveSelectedWorktree = React.useCallback(async () => { + if (!selectedSession) return; + setWorktreeDialog({ kind: 'remove', path: selectedSession.path, label: selectedSession.displayLabel }); + }, [selectedSession]); + + const handleKeepOnlySelectedWorktree = React.useCallback(async () => { + if (!selectedSession) return; + setWorktreeDialog({ kind: 'keepOnly', path: selectedSession.path, label: selectedSession.displayLabel }); + }, [selectedSession]); + + const handleConfirmWorktreeAction = React.useCallback(async () => { + if (!worktreeDialog || isProcessing) return; + setIsProcessing(true); + try { + if (worktreeDialog.kind === 'remove') { + toast.info('Removing worktree...'); + const ok = await deleteGroupWorktree(group.name, worktreeDialog.path); + if (ok) { + toast.success('Worktree removed'); + } else { + const error = useAgentGroupsStore.getState().error; + toast.error(error || 'Failed to remove worktree'); + return; + } + } else { + toast.info('Removing other worktrees...'); + const ok = await keepOnlyGroupWorktree(group.name, worktreeDialog.path); + if (ok) { + toast.success('Removed other worktrees'); + } else { + const error = useAgentGroupsStore.getState().error; + toast.error(error || 'Failed to remove other worktrees'); + return; + } + } + setWorktreeDialog(null); + } finally { + setIsProcessing(false); + } + }, [deleteGroupWorktree, group.name, isProcessing, keepOnlyGroupWorktree, worktreeDialog]); + return (
{/* Header */} @@ -90,7 +143,8 @@ export const AgentGroupDetail: React.FC = ({ {/* Model Selector Dropdown */} {group.sessions.length > 0 && ( -
+
+
+ + + + + + + { + e.preventDefault(); + void handleRemoveSelectedWorktree(); + }} + variant="destructive" + > + Remove this worktree + + { + e.preventDefault(); + void handleKeepOnlySelectedWorktree(); + }} + > + Leave this one, remove others + + +
)}
+ + { if (!open) setWorktreeDialog(null); }}> + + + + {worktreeDialog?.kind === 'remove' ? 'Remove worktree' : 'Remove other worktrees'} + + + {worktreeDialog?.kind === 'remove' + ? <>Remove {worktreeDialog?.label}? This deletes all sessions in that worktree and removes the worktree itself. + : <>Keep {worktreeDialog?.label} and remove the other worktrees in {group.name}.} + + + + + + + + {/* Chat Content */}
diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx index 9e324d87..627bfbfe 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx @@ -16,6 +16,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect'; import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector'; import { AgentSelector } from '@/components/multirun/AgentSelector'; +import { isIMECompositionEvent } from '@/lib/ime'; import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun'; /** Max file size in bytes (10MB) */ @@ -23,16 +24,6 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024; /** Max number of concurrent runs */ const MAX_MODELS = 5; -/** - * Detects if a keyboard event is part of IME composition. - * Uses both isComposing and keyCode === 229 (MDN recommended). - * WebKit may fire compositionend before keydown, causing isComposing to be false - * while keyCode remains 229, so both checks are needed. - */ -const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => { - return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229; -}; - /** Attached file for agent manager */ interface AttachedFile { id: string; @@ -191,7 +182,7 @@ export const AgentManagerEmptyState: React.FC = ({ if (isIMECompositionEvent(e)) return; // Enter submits if valid, Shift+Enter adds newline - if (e.key === 'Enter' && !e.shiftKey && !isIMECompositionEvent(e)) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (isValid && !isSubmittingOrCreating) { handleSubmit(e as unknown as React.FormEvent); @@ -247,7 +238,7 @@ export const AgentManagerEmptyState: React.FC = ({ onChange={setSelectedAgent} />

- Optional agent to use for all runs + Defaults to your configured default agent

diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx index 9b8e3ed3..1f29f980 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx @@ -10,12 +10,6 @@ import { toast } from 'sonner'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, @@ -24,6 +18,12 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -50,121 +50,105 @@ interface AgentGroupItemProps { const AgentGroupItem: React.FC = ({ group, isSelected, onSelect }) => { const [menuOpen, setMenuOpen] = React.useState(false); - const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); + const [confirmOpen, setConfirmOpen] = React.useState(false); const [isDeleting, setIsDeleting] = React.useState(false); const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup); - - const handleDelete = async () => { + + const handleDeleteGroup = React.useCallback(async () => { + if (isDeleting) return; setIsDeleting(true); - try { - const { success, deletedCount, failedCount } = await deleteGroup(group.name); - - if (success) { - toast.success(`Deleted agent group "${group.name}"`, { - description: `${deletedCount} session${deletedCount !== 1 ? 's' : ''} removed with worktrees archived.`, - }); - } else if (deletedCount > 0) { - toast.warning(`Partially deleted agent group "${group.name}"`, { - description: `${deletedCount} deleted, ${failedCount} failed.`, - }); - } else { - toast.error(`Failed to delete agent group "${group.name}"`); - } - } catch (error) { - toast.error(`Failed to delete agent group "${group.name}"`); - console.error('Delete group error:', error); - } finally { - setIsDeleting(false); - setShowDeleteConfirm(false); + toast.info(`Deleting "${group.name}"...`); + const ok = await deleteGroup(group.name); + if (ok) { + toast.success(`Deleted "${group.name}"`); + } else { + const error = useAgentGroupsStore.getState().error; + toast.error(error || `Failed to delete "${group.name}"`); } - }; + setIsDeleting(false); + setConfirmOpen(false); + }, [deleteGroup, group.name, isDeleting]); return ( -
-
- + +
+ + + + + + { + e.stopPropagation(); + setMenuOpen(false); + setConfirmOpen(true); + }} + > + Delete + + +
- - -
- - - - - - { - e.stopPropagation(); - setMenuOpen(false); - setShowDeleteConfirm(true); - }} - > - Delete - - -
- - - + + + - Delete Agent Group + Delete agent group - Are you sure you want to delete "{group.name}"? This will remove {group.sessionCount} session{group.sessionCount !== 1 ? 's' : ''} and archive their worktrees. This action cannot be undone. + Delete {group.name}? This removes all worktrees and sessions in this group. - - -
+ ); }; diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx index 5dec4b6a..d45fdde1 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx @@ -6,6 +6,10 @@ import { AgentGroupDetail } from './AgentGroupDetail'; import { cn } from '@/lib/utils'; import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import type { CreateMultiRunParams } from '@/types/multirun'; interface AgentManagerViewProps { @@ -13,6 +17,25 @@ 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 [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>( + () => + (typeof window !== 'undefined' + ? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as + 'connecting' | 'connected' | 'error' | 'disconnected' | undefined + : 'connecting') || 'connecting' + ); + const configInitialized = useConfigStore((state) => state.isInitialized); + const initializeApp = useConfigStore((state) => state.initializeApp); + const loadSessions = useSessionStore((state) => state.loadSessions); + const setDirectory = useDirectoryStore((state) => state.setDirectory); + const bootstrapAttemptAt = React.useRef(0); + const { selectedGroupName, selectGroup, @@ -22,6 +45,86 @@ export const AgentManagerView: React.FC = ({ className }) const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore(); + React.useEffect(() => { + if (!isVSCodeRuntime) { + return; + } + + const current = + (typeof window !== 'undefined' + ? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status + : undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined; + if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') { + setConnectionStatus(current); + } + + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail; + const status = detail?.status; + if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') { + setConnectionStatus(status); + } + }; + window.addEventListener('openchamber:connection-status', handler as EventListener); + return () => window.removeEventListener('openchamber:connection-status', handler as EventListener); + }, [isVSCodeRuntime]); + + React.useEffect(() => { + if (!isVSCodeRuntime || connectionStatus !== 'connected') { + return; + } + + const now = Date.now(); + if (now - bootstrapAttemptAt.current < 750) { + return; + } + bootstrapAttemptAt.current = now; + + const workspaceFolder = (typeof window !== 'undefined' + ? (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder + : null); + + if (typeof workspaceFolder === 'string' && workspaceFolder.trim().length > 0) { + try { + setDirectory(workspaceFolder, { showOverlay: false }); + } catch { + // ignored + } + } + + const runBootstrap = async () => { + try { + if (!configInitialized) { + await initializeApp(); + } + + const configState = useConfigStore.getState(); + if ( + !configState.isInitialized || + !configState.isConnected || + configState.providers.length === 0 || + configState.agents.length === 0 + ) { + return; + } + + await loadSessions(); + + if (streamDebugEnabled()) { + console.log('[OpenChamber][VSCode][agentManager] bootstrap complete', { + providers: configState.providers.length, + agents: configState.agents.length, + sessions: useSessionStore.getState().sessions.length, + }); + } + } catch { + // ignored + } + }; + + void runBootstrap(); + }, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, loadSessions, setDirectory]); + const handleGroupSelect = React.useCallback((groupName: string) => { selectGroup(groupName); }, [selectGroup]); @@ -38,10 +141,29 @@ export const AgentManagerView: React.FC = ({ className }) if (result) { toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`); - // Reload groups to pick up the new worktrees and sessions - await loadGroups(); - // Select the newly created group - selectGroup(params.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 50)); + const groupSlug = result.groupSlug; + + const waitForGroup = async (attempts = 6) => { + for (let attempt = 0; attempt < attempts; attempt += 1) { + await loadGroups(); + const groupsState = useAgentGroupsStore.getState(); + if (groupsState.groups.some((group) => group.name === groupSlug)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return false; + }; + + // Refresh sessions + groups and wait briefly for OpenCode to surface the new worktree sessions. + try { + await useSessionStore.getState().loadSessions(); + } catch { + // ignore + } + + await waitForGroup(); + selectGroup(groupSlug); } else { const error = useMultiRunStore.getState().error; toast.error(error || 'Failed to create agent group'); diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index dbfb959e..3f0cc9e4 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -1,11 +1,12 @@ import React from 'react'; -import { opencodeClient } from '@/lib/opencode/client'; +import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'; import { saveSessionCursor } from '@/lib/messageCursorPersistence'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import type { Part, Session, Message, Permission } from '@opencode-ai/sdk/v2'; +import type { Part, Session, Message } from '@opencode-ai/sdk/v2'; +import type { PermissionRequest } from '@/types/permission'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { handleTodoUpdatedEvent } from '@/stores/useTodoStore'; @@ -93,7 +94,9 @@ export const useEventStream = () => { sessions, getWorktreeMetadata, loadMessages, - loadSessions + loadSessions, + updateSession, + removeSessionFromStore } = useSessionStore(); const { checkConnection } = useConfigStore(); @@ -127,6 +130,31 @@ export const useEventStream = () => { return undefined; }, [activeSessionDirectory, fallbackDirectory]); + React.useEffect(() => { + let cancelled = false; + + const bootstrapPendingPermissions = async () => { + try { + const pending = await opencodeClient.listPendingPermissions(); + if (cancelled || pending.length === 0) { + return; + } + + pending.forEach((request) => { + addPermission(request as unknown as PermissionRequest); + }); + } catch { + // ignored + } + }; + + void bootstrapPendingPermissions(); + + return () => { + cancelled = true; + }; + }, [addPermission]); + const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -250,6 +278,7 @@ export const useEventStream = () => { const isCleaningUpRef = React.useRef(false); const resyncInFlightRef = React.useRef | null>(null); const lastResyncAtRef = React.useRef(0); + const permissionToastShownRef = React.useRef>(new Set()); const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => { if (typeof document === 'undefined') return 'visible'; @@ -265,7 +294,6 @@ export const useEventStream = () => { const staleCheckIntervalRef = React.useRef(null); const lastEventTimestampRef = React.useRef(Date.now()); const isDesktopRuntimeRef = React.useRef(false); - const activityStreamAbortControllerRef = React.useRef(null); const maybeBootstrapIfStale = React.useCallback( (reason: string) => { @@ -299,7 +327,7 @@ export const useEventStream = () => { }, [currentSessionId]); const requestSessionMetadataRefresh = React.useCallback( - (sessionId: string | undefined | null) => { + (sessionId: string | undefined | null, directoryOverride?: string | null) => { if (!sessionId) return; const now = Date.now(); @@ -310,9 +338,35 @@ export const useEventStream = () => { timestamps.set(sessionId, now); + const resolveDirectoryForSession = (id: string): string | null => { + if (typeof directoryOverride === 'string' && directoryOverride.trim().length > 0) { + return directoryOverride.trim(); + } + + try { + const metadata = getWorktreeMetadata?.(id); + if (metadata?.path) { + return metadata.path; + } + } catch { + // ignored + } + + const sessionRecord = sessions.find((entry) => entry.id === id) as Session & { directory?: string | null }; + if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) { + return sessionRecord.directory.trim(); + } + + return null; + }; + setTimeout(async () => { try { - const session = await opencodeClient.getSession(sessionId); + const directory = resolveDirectoryForSession(sessionId); + const session = directory + ? await opencodeClient.withDirectory(directory, () => opencodeClient.getSession(sessionId)) + : await opencodeClient.getSession(sessionId); + if (session) { const patch: Partial = {}; if (typeof session.title === 'string' && session.title.length > 0) { @@ -330,21 +384,9 @@ export const useEventStream = () => { } }, 100); }, - [applySessionMetadata] + [applySessionMetadata, getWorktreeMetadata, sessions] ); - const requestSessionListRefresh = React.useCallback(() => { - if (sessionRefreshTimeoutRef.current) return; - - sessionRefreshTimeoutRef.current = setTimeout(() => { - sessionRefreshTimeoutRef.current = null; - try { - void loadSessions(); - } catch (error) { - console.warn('Failed to refresh sessions after stream completion:', error); - } - }, 500); - }, [loadSessions]); const updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => { const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId); @@ -390,12 +432,28 @@ export const useEventStream = () => { sessionStatusLastRefreshAtRef.current = now; const applyStatusMap = (statusMap: Record) => { + const observed = new Set(); + const knownSessionIds = new Set(sessions.map((session) => session.id)); + Object.entries(statusMap).forEach(([sessionId, raw]) => { if (!sessionId || !raw) return; + observed.add(sessionId); const phase: 'idle' | 'busy' = raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle'; updateSessionActivityPhase(sessionId, phase); }); + + // OpenCode's /session/status may omit idle sessions (returns only busy/retry). + // Treat missing entries as idle to avoid sessions getting stuck "working". + const currentPhases = useSessionStore.getState().sessionActivityPhase; + if (!currentPhases) return; + + for (const [sessionId, phase] of currentPhases.entries()) { + if (!knownSessionIds.has(sessionId)) continue; + if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) { + updateSessionActivityPhase(sessionId, 'idle'); + } + } }; const task = (async (): Promise => { @@ -435,6 +493,19 @@ export const useEventStream = () => { Object.assign(merged, result.value); }); + if (Object.keys(merged).length === 0) { + const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some( + (phase) => phase === 'busy' || phase === 'cooldown' + ); + + if (hasActivePhases) { + const healthy = await opencodeClient.checkHealth().catch(() => false); + if (!healthy) { + return; + } + } + } + applyStatusMap(merged); } catch { // ignored @@ -463,92 +534,6 @@ export const useEventStream = () => { previousSessionDirectoryRef.current = nextDirectory; }, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]); - const handleActivityEvent = React.useCallback((event: EventData) => { - if (!event?.type) return; - - const props = (event.properties ?? {}) as Record; - - if (event.type === 'openchamber:session-activity') { - const sessionId = - typeof props.sessionId === 'string' - ? props.sessionId - : typeof props.sessionID === 'string' - ? props.sessionID - : null; - const phase = typeof props.phase === 'string' ? props.phase : null; - if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) { - updateSessionActivityPhase(sessionId, phase); - requestSessionListRefresh(); - } - return; - } - - if (event.type === 'session.status') { - const sessionId = - typeof props.sessionID === 'string' - ? props.sessionID - : typeof props.sessionId === 'string' - ? props.sessionId - : null; - const statusObj = - typeof props.status === 'object' && props.status !== null - ? (props.status as Record) - : null; - const statusType = typeof statusObj?.type === 'string' ? (statusObj.type as string) : null; - - if (sessionId && statusType) { - updateSessionActivityPhase( - sessionId, - statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle', - ); - requestSessionListRefresh(); - } - return; - } - - if (event.type === 'session.idle') { - const sessionId = - typeof props.sessionID === 'string' - ? props.sessionID - : typeof props.sessionId === 'string' - ? props.sessionId - : null; - if (sessionId) { - updateSessionActivityPhase(sessionId, 'idle'); - requestSessionListRefresh(); - } - return; - } - - if (event.type === 'message.updated' || event.type === 'message.part.updated') { - const messageInfo = - typeof props.info === 'object' && props.info !== null ? (props.info as Record) : props; - - const sessionId = - typeof (messageInfo as { sessionID?: unknown }).sessionID === 'string' - ? (messageInfo as { sessionID?: string }).sessionID - : typeof (messageInfo as { sessionId?: unknown }).sessionId === 'string' - ? (messageInfo as { sessionId?: string }).sessionId - : typeof props.sessionID === 'string' - ? (props.sessionID as string) - : typeof props.sessionId === 'string' - ? (props.sessionId as string) - : null; - - const role = (messageInfo as { role?: unknown }).role; - const finish = (messageInfo as { finish?: unknown }).finish; - - if (sessionId && role === 'assistant' && finish === 'stop') { - const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId); - if (currentPhase === 'busy') { - updateSessionActivityPhase(sessionId, 'cooldown'); - requestSessionListRefresh(); - } - } - return; - } - }, [requestSessionListRefresh, updateSessionActivityPhase]); - const handleEvent = React.useCallback((event: EventData) => { lastEventTimestampRef.current = Date.now(); @@ -603,8 +588,7 @@ export const useEventStream = () => { const phase = typeof props.phase === 'string' ? props.phase : null; if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) { updateSessionActivityPhase(sessionId, phase); - // Refresh session list on activity changes (same trigger as activity indication) - requestSessionListRefresh(); + requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null); } break; } @@ -621,8 +605,7 @@ export const useEventStream = () => { sessionId, statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle', ); - // Refresh session list on status changes (same trigger as activity indication) - requestSessionListRefresh(); + requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null); } } break; @@ -1009,11 +992,15 @@ export const useEventStream = () => { } const rawMessageSessionId = (message as { sessionID?: string }).sessionID; - const messageSessionId: string = - typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0 + const messageSessionId: string = + typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0 ? rawMessageSessionId : sessionId; - requestSessionMetadataRefresh(messageSessionId); + requestSessionMetadataRefresh( + messageSessionId, + typeof props.directory === 'string' ? props.directory : null, + ); + const summaryInfo = message as Message & { summary?: boolean }; if (summaryInfo.summary && typeof messageSessionId === 'string') { @@ -1023,6 +1010,7 @@ export const useEventStream = () => { break; } + case 'session.created': case 'session.updated': { const candidate = (typeof props.info === 'object' && props.info !== null) ? props.info as Record : (typeof props.sessionInfo === 'object' && props.sessionInfo !== null) ? props.sessionInfo as Record : @@ -1038,6 +1026,32 @@ export const useEventStream = () => { (typeof props.time === 'object' && props.time !== null) ? props.time as Record : null; const compactingTimestamp = timeSource && typeof timeSource.compacting === 'number' ? timeSource.compacting as number : null; updateSessionCompaction(sessionId, compactingTimestamp); + + const sessionDirectory = typeof (candidate as { directory?: unknown }).directory === 'string' + ? (candidate as { directory: string }).directory + : typeof props.directory === 'string' + ? (props.directory as string) + : null; + + const patchedSession = { + ...(candidate as unknown as Record), + id: sessionId, + ...(sessionDirectory ? { directory: sessionDirectory } : {}), + } as unknown as Session; + + updateSession(patchedSession); + } + break; + } + + case 'session.deleted': { + const sessionId = typeof props.sessionID === 'string' + ? props.sessionID + : typeof props.id === 'string' + ? props.id + : null; + if (sessionId) { + removeSessionFromStore(sessionId); } break; } @@ -1058,56 +1072,65 @@ export const useEventStream = () => { break; } - case 'permission.updated': - if (currentSessionId === props.sessionID) { - addPermission(props as unknown as Permission); + case 'permission.asked': { + if (!('sessionID' in props) || typeof props.sessionID !== 'string') { + break; } - break; - case 'permission.asked': - // New permission system from OpenCode's PermissionNext - if ('sessionID' in props && props.sessionID === currentSessionId) { - const askedProps = props as { - id: string; - permission: string; - sessionID: string; - patterns?: string[]; - always?: string[]; - metadata: Record; - tool?: { - messageID: string; - callID: string; - }; - }; + const request = props as unknown as PermissionRequest; - // Convert new permission.asked event format to Permission type - const permission = { - id: askedProps.id, - type: askedProps.permission, - pattern: askedProps.patterns, // Map patterns to pattern field for compatibility - sessionID: askedProps.sessionID, - messageID: askedProps.tool?.messageID || askedProps.sessionID, - callID: askedProps.tool?.callID, - title: `${askedProps.permission} permission required`, - metadata: { - ...askedProps.metadata, - always: askedProps.always, // Store always in metadata for UI access - patterns: askedProps.patterns, - }, - time: { created: Date.now() }, - } as unknown as Permission; - addPermission(permission); + addPermission(request); + + // Notify if permission is for another session (common with child sessions). + const toastKey = `${request.sessionID}:${request.id}`; + if (!permissionToastShownRef.current.has(toastKey)) { + setTimeout(() => { + const current = currentSessionIdRef.current; + if (current === request.sessionID) { + return; + } + + const pending = useSessionStore + .getState() + .permissions + .get(request.sessionID) + ?.some((entry) => entry.id === request.id); + + if (!pending) { + return; + } + + permissionToastShownRef.current.add(toastKey); + + const sessionTitle = + useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title || + 'Session'; + + import('sonner').then(({ toast }) => { + toast.warning('Permission required', { + description: sessionTitle, + action: { + label: 'Open', + onClick: () => { + useUIStore.getState().setActiveMainTab('chat'); + void useSessionStore.getState().setCurrentSession(request.sessionID); + }, + }, + }); + }); + }, 0); } + break; + } case 'permission.replied': - // Permission was responded to - UI will update via permissionStore break; case 'todo.updated': { const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; - const todos = Array.isArray(props.todos) ? props.todos : []; - if (sessionId && todos.length > 0) { + const todos = Array.isArray(props.todos) ? props.todos : null; + if (sessionId && todos) { handleTodoUpdatedEvent( sessionId, todos as Array<{ id: string; content: string; status: string; priority: string }> @@ -1124,12 +1147,13 @@ export const useEventStream = () => { addPermission, checkConnection, requestSessionMetadataRefresh, - requestSessionListRefresh, updateSessionCompaction, applySessionMetadata, trackMessage, reportMessage, updateSessionActivityPhase, + updateSession, + removeSessionFromStore, bootstrapState ]); @@ -1144,7 +1168,6 @@ export const useEventStream = () => { console.debug('[useEventStream] Connection state:', { isDesktopRuntime: isDesktopRuntimeRef.current, hasUnsubscribe: Boolean(unsubscribeRef.current), - hasActivityStream: Boolean(activityStreamAbortControllerRef.current), currentSessionId: currentSessionIdRef.current, effectiveDirectory, onlineStatus: onlineStatusRef.current, @@ -1183,14 +1206,6 @@ export const useEventStream = () => { } } - if (activityStreamAbortControllerRef.current) { - try { - activityStreamAbortControllerRef.current.abort(); - } catch (error) { - console.warn('[useEventStream] Error during activity stream abort:', error); - } - activityStreamAbortControllerRef.current = null; - } isCleaningUpRef.current = false; }, []); @@ -1239,9 +1254,9 @@ export const useEventStream = () => { lastEventTimestampRef.current = Date.now(); publishStatus('connected', null); checkConnection(); - + const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some( - (phase) => phase === 'busy' + (phase) => phase === 'busy' || phase === 'cooldown' ); if (hasBusySessions) { void refreshSessionActivityStatus(); @@ -1278,143 +1293,29 @@ export const useEventStream = () => { } try { - const sdkUnsub = opencodeClient.subscribeToEvents( - handleEvent, + const sdkUnsub = opencodeClient.subscribeToGlobalEvents( + (event: RoutedOpencodeEvent) => { + const payload = event.payload as unknown as EventData; + const payloadRecord = event.payload as unknown as Record; + const baseProperties = + typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null + ? (payloadRecord.properties as Record) + : {}; + + const properties = + event.directory && event.directory !== 'global' + ? { ...baseProperties, directory: event.directory } + : baseProperties; + + handleEvent({ + type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '', + properties, + }); + }, onError, onOpen, - effectiveDirectory, - { scope: 'directory', key: 'events' } ); - if (!isDesktopRuntimeRef.current) { - if (activityStreamAbortControllerRef.current) { - activityStreamAbortControllerRef.current.abort(); - } - - const activityAbortController = new AbortController(); - activityStreamAbortControllerRef.current = activityAbortController; - - const parseSseEventBlock = (block: string): EventData | null => { - if (!block) return null; - - const dataLines = block - .split('\n') - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).replace(/^\s/, '')); - - if (dataLines.length === 0) { - return null; - } - - const payloadText = dataLines.join('\n').trim(); - if (!payloadText) { - return null; - } - - try { - const parsed = JSON.parse(payloadText) as unknown; - if (!parsed || typeof parsed !== 'object') { - return null; - } - - const record = parsed as Record; - if (typeof record.type === 'string') { - return record as unknown as EventData; - } - - const nestedPayload = record.payload; - if (nestedPayload && typeof nestedPayload === 'object') { - const nestedRecord = nestedPayload as Record; - if (typeof nestedRecord.type === 'string') { - return nestedRecord as unknown as EventData; - } - } - - return null; - } catch { - return null; - } - }; - - void (async () => { - try { - const candidateEndpoints = ['/api/global/event', '/api/event']; - let response: Response | null = null; - let lastError: unknown = null; - - for (const endpoint of candidateEndpoints) { - try { - const candidateResponse = await fetch(endpoint, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'Cache-Control': 'no-cache', - }, - signal: activityAbortController.signal, - }); - - if (candidateResponse.ok && candidateResponse.body) { - response = candidateResponse; - if (streamDebugEnabled()) { - console.info('[useEventStream] Activity stream connected:', endpoint); - } - break; - } - - lastError = new Error(`Activity stream failed: ${candidateResponse.status}`); - } catch (error) { - lastError = error; - } - } - - if (!response) { - throw lastError ?? new Error('Activity stream failed'); - } - - const responseBody = response.body; - if (!responseBody) { - throw new Error('Activity stream missing body'); - } - - const reader = responseBody.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (activityAbortController.signal.aborted) break; - if (!value || value.length === 0) continue; - - buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - const blocks = buffer.split('\n\n'); - buffer = blocks.pop() ?? ''; - for (const block of blocks) { - const event = parseSseEventBlock(block); - if (event) { - handleActivityEvent(event); - } - } - } - - const remaining = buffer.trim(); - if (remaining) { - const event = parseSseEventBlock(remaining); - if (event) { - handleActivityEvent(event); - } - } - } catch (error) { - if (!activityAbortController.signal.aborted) { - console.warn('[useEventStream] Activity stream error:', error); - } - } finally { - if (activityStreamAbortControllerRef.current === activityAbortController) { - activityStreamAbortControllerRef.current = null; - } - } - })(); - } const compositeUnsub = () => { try { @@ -1428,10 +1329,6 @@ export const useEventStream = () => { unsubscribeRef.current = compositeUnsub; } else { compositeUnsub(); - if (activityStreamAbortControllerRef.current) { - activityStreamAbortControllerRef.current.abort(); - activityStreamAbortControllerRef.current = null; - } } } catch (subscriptionError) { console.error('[useEventStream] Error during subscription:', subscriptionError); @@ -1445,7 +1342,6 @@ export const useEventStream = () => { resyncMessages, requestSessionMetadataRefresh, handleEvent, - handleActivityEvent, effectiveDirectory, refreshSessionActivityStatus, waitForDesktopBridge, @@ -1499,8 +1395,7 @@ export const useEventStream = () => { const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null; if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) { updateSessionActivityPhase(sessionId, phase); - // Refresh session list on activity changes (same trigger as activity indication) - requestSessionListRefresh(); + requestSessionMetadataRefresh(sessionId); } }; window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener); @@ -1543,10 +1438,9 @@ export const useEventStream = () => { resyncMessages(sessionId, 'visibility_restore').catch(() => {}); requestSessionMetadataRefresh(sessionId); } - - void loadSessions(); - void refreshSessionActivityStatus(); - publishStatus('connecting', 'Resuming stream'); + + void refreshSessionActivityStatus(); + publishStatus('connecting', 'Resuming stream'); startStream({ resetAttempts: true }); } } else { @@ -1571,7 +1465,6 @@ export const useEventStream = () => { .then(() => console.info('[useEventStream] Messages refreshed on focus')) .catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err)); } - void loadSessions(); void refreshSessionActivityStatus(); publishStatus('connecting', 'Resuming stream'); @@ -1619,7 +1512,7 @@ export const useEventStream = () => { const now = Date.now(); const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some( - (phase) => phase === 'busy' + (phase) => phase === 'busy' || phase === 'cooldown' ); if (hasBusySessions) { void refreshSessionActivityStatus(); @@ -1692,7 +1585,6 @@ export const useEventStream = () => { scheduleReconnect, loadMessages, requestSessionMetadataRefresh, - requestSessionListRefresh, updateSessionActivityPhase, refreshSessionActivityStatus, shouldHoldConnection, diff --git a/packages/ui/src/hooks/useFileSystemAccess.ts b/packages/ui/src/hooks/useFileSystemAccess.ts index 1a125ff9..4c96a547 100644 --- a/packages/ui/src/hooks/useFileSystemAccess.ts +++ b/packages/ui/src/hooks/useFileSystemAccess.ts @@ -8,7 +8,7 @@ export const useFileSystemAccess = () => { setIsDesktop(isDesktopRuntime()); }, []); - const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; error?: string }> => { + const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => { if (!isDesktop) { return { success: true, path: directoryPath }; } diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index e40d72b0..04d6784a 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -2,11 +2,12 @@ import React from 'react'; import { toast } from 'sonner'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { sessionEvents } from '@/lib/sessionEvents'; import { isDesktopRuntime } from '@/lib/desktop'; +import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; const MENU_ACTION_EVENT = 'openchamber:menu-action'; @@ -42,20 +43,36 @@ export const useMenuActions = ( setSettingsDialogOpen, setAboutDialogOpen, } = useUIStore(); - const { setDirectory } = useDirectoryStore(); + const { addProject } = useProjectsStore(); + const { requestAccess, startAccessing } = useFileSystemAccess(); const { setThemeMode } = useThemeSystem(); const isDownloadingLogsRef = React.useRef(false); const handleChangeWorkspace = React.useCallback(() => { - if (isDesktopRuntime() && window.opencodeDesktop?.requestDirectoryAccess) { - window.opencodeDesktop - .requestDirectoryAccess('') - .then((result) => { - if (result.success && result.path) { - setDirectory(result.path, { showOverlay: true }); - } else if (result.error && result.error !== 'Directory selection cancelled') { - toast.error('Failed to select directory', { - description: result.error, + if (isDesktopRuntime()) { + requestAccess('') + .then(async (result) => { + if (!result.success || !result.path) { + if (result.error && result.error !== 'Directory selection cancelled') { + toast.error('Failed to select directory', { + description: result.error, + }); + } + return; + } + + const accessResult = await startAccessing(result.path); + if (!accessResult.success) { + toast.error('Failed to open directory', { + description: accessResult.error || 'Desktop could not grant file access.', + }); + return; + } + + const added = addProject(result.path, { id: result.projectId }); + if (!added) { + toast.error('Failed to add project', { + description: 'Please select a valid directory path.', }); } }) @@ -66,7 +83,7 @@ export const useMenuActions = ( } else { sessionEvents.requestDirectoryDialog(); } - }, [setDirectory]); + }, [addProject, requestAccess, startAccessing]); React.useEffect(() => { const handleMenuAction = (event: Event) => { diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 6e6cb304..304d42b3 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -407,6 +407,7 @@ svg.animate-spin { --sidebar-accent-foreground: oklch(0.25 0.02 40); /* Dark warm text */ --sidebar-border: oklch(0.85 0.02 70); /* Warm border */ --sidebar-ring: oklch(0.65 0.2 55); /* Focus ring */ + --sidebar-stuck-bg: #DDDDD3; /* Desktop sidebar sticky header background */ } .dark { @@ -443,6 +444,7 @@ svg.animate-spin { --sidebar-accent-foreground: oklch(0.85 0.02 90); /* #cdccc3 */ --sidebar-border: oklch(0.31 0.01 35); /* #393836 */ --sidebar-ring: oklch(0.77 0.17 85); /* #edb449 */ + --sidebar-stuck-bg: #1F1F1D; /* Desktop sidebar sticky header background */ } * { diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 8ae441fc..90e79ea3 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -322,6 +322,14 @@ export interface FilesAPI { createDirectory(path: string): Promise<{ success: boolean; path: string }>; } +export interface ProjectEntry { + id: string; + path: string; + label?: string; + addedAt?: number; + lastOpenedAt?: number; +} + export interface SettingsPayload { themeId?: string; useSystemTheme?: boolean; @@ -330,6 +338,8 @@ export interface SettingsPayload { darkThemeId?: string; lastDirectory?: string; homeDirectory?: string; + projects?: ProjectEntry[]; + activeProjectId?: string; approvedDirectories?: string[]; securityScopedBookmarks?: string[]; pinnedDirectories?: string[]; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 6340a2eb..75c33ac8 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -1,3 +1,5 @@ +import type { ProjectEntry } from '@/lib/api/types'; + export type AssistantNotificationPayload = { title?: string; body?: string; @@ -43,6 +45,8 @@ export type DesktopSettings = { darkThemeId?: string; lastDirectory?: string; homeDirectory?: string; + projects?: ProjectEntry[]; + activeProjectId?: string; approvedDirectories?: string[]; securityScopedBookmarks?: string[]; pinnedDirectories?: string[]; @@ -72,7 +76,7 @@ export type DesktopApi = { getHomeDirectory?: () => Promise<{ success: boolean; path: string | null }>; getSettings?: () => Promise; updateSettings?: (changes: Partial) => Promise; - requestDirectoryAccess?: (path: string) => Promise<{ success: boolean; path?: string; error?: string }>; + requestDirectoryAccess?: (path: string) => Promise<{ success: boolean; path?: string; projectId?: string; error?: string }>; startAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>; stopAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>; notifyAssistantCompletion?: (payload?: AssistantNotificationPayload) => Promise<{ success: boolean }>; @@ -205,7 +209,7 @@ export const updateDesktopSettings = async ( export const requestDirectoryAccess = async ( directoryPath: string -): Promise<{ success: boolean; path?: string; error?: string }> => { +): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => { const api = getDesktopApi(); if (!api || !api.requestDirectoryAccess) { return { success: true, path: directoryPath }; diff --git a/packages/ui/src/lib/ime.ts b/packages/ui/src/lib/ime.ts new file mode 100644 index 00000000..ed2bb77e --- /dev/null +++ b/packages/ui/src/lib/ime.ts @@ -0,0 +1,14 @@ +import type React from 'react'; + +/** + * Detects if a keyboard event is part of IME composition. + * Uses both `isComposing` and the `keyCode === 229` fallback. + * + * Note: `keyCode` is deprecated, but `229` remains a practical fallback for + * some WebKit-based environments (including Tauri WebView) where composition + * events can be ordered unexpectedly. + */ +export const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => { + return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229; +}; + diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 73e1d645..2e5b0efe 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -13,6 +13,7 @@ import type { FilePartInput, Event, } from "@opencode-ai/sdk/v2"; +import type { PermissionRequest } from "@/types/permission"; type StreamEvent = { data: TData; event?: string; @@ -20,6 +21,11 @@ type StreamEvent = { retry?: number; }; +export type RoutedOpencodeEvent = { + directory: string; + payload: Event; +}; + // Use relative path by default (works with both dev and nginx proxy server) // Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api"; @@ -132,6 +138,14 @@ class OpencodeService { private sseAbortControllers: Map = new Map(); private currentDirectory: string | undefined = undefined; + private globalSseAbortController: AbortController | null = null; + private globalSseTask: Promise | null = null; + private globalSseLastEventId: string | undefined; + private globalSseIsConnected = false; + private globalSseListeners: Set<(event: RoutedOpencodeEvent) => void> = new Set(); + private globalSseOpenListeners: Set<() => void> = new Set(); + private globalSseErrorListeners: Set<(error: unknown) => void> = new Set(); + constructor(baseUrl: string = DEFAULT_BASE_URL) { const desktopBase = resolveDesktopBaseUrl(); const requestedBaseUrl = desktopBase || baseUrl; @@ -726,21 +740,46 @@ class OpencodeService { return this.getSessionStatusForDirectory(null); } + // Tools + async listToolIds(options?: { directory?: string | null }): Promise { + try { + const directory = typeof options?.directory === 'string' + ? options.directory.trim() + : (this.currentDirectory ? this.currentDirectory.trim() : ''); + + const result = await this.client.tool.ids(directory ? { directory } : undefined); + const tools = (result.data || []) as unknown as string[]; + return tools.filter((tool) => typeof tool === 'string' && tool !== 'invalid'); + } catch { + return []; + } + } + // Permissions - async respondToPermission( - sessionId: string, - permissionId: string, - response: 'once' | 'always' | 'reject' + async replyToPermission( + requestId: string, + reply: 'once' | 'always' | 'reject', + options?: { message?: string } ): Promise { - const result = await this.client.permission.respond({ - sessionID: sessionId, - permissionID: permissionId, + const result = await this.client.permission.reply({ + requestID: requestId, ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), - response + reply, + ...(options?.message ? { message: options.message } : {}), }); return result.data || false; } + async listPendingPermissions(): Promise { + try { + // Permission requests are global across sessions; do not scope by directory. + const result = await this.client.permission.list(); + return (result.data || []) as unknown as PermissionRequest[]; + } catch { + return []; + } + } + // Configuration async getConfig(): Promise { const response = await this.client.config.get(); @@ -773,7 +812,7 @@ class OpencodeService { /** * Update config with a partial modification function. - * This handles the GET-modify-PATCH pattern required by OpenCode API. + * This handles the GET-modify-PATCH pattern required by the upstream API. * * NOTE: This method is deprecated for agent configuration. * Use backend endpoints at /api/config/agents/* instead, which write directly to files. @@ -830,6 +869,286 @@ class OpencodeService { } } + private parseSseBlock(block: string): { data: unknown; id?: string } | null { + if (!block) return null; + + const lines = block.split('\n'); + const dataLines: string[] = []; + let eventId: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).replace(/^\s/, '')); + } else if (line.startsWith('id:')) { + const candidate = line.slice(3).trim(); + if (candidate) { + eventId = candidate; + } + } + } + + if (dataLines.length === 0) { + return null; + } + + const payloadText = dataLines.join('\n').trim(); + if (!payloadText) { + return null; + } + + try { + const data = JSON.parse(payloadText) as unknown; + return { data, id: eventId }; + } catch { + return null; + } + } + + private normalizeRoutedSsePayload(raw: unknown): RoutedOpencodeEvent | null { + if (!raw || typeof raw !== 'object') { + return null; + } + + const record = raw as Record; + + const directoryCandidate = + typeof record.directory === 'string' + ? record.directory + : typeof record.properties === 'object' && record.properties !== null + ? ((record.properties as Record).directory as unknown) + : null; + + const normalizedDirectory = + typeof directoryCandidate === 'string' + ? this.normalizeCandidatePath(directoryCandidate) ?? directoryCandidate.trim() + : null; + + if (typeof record.type === 'string') { + return { + directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global', + payload: record as Event, + }; + } + + const nestedPayload = record.payload; + if (nestedPayload && typeof nestedPayload === 'object') { + const nestedRecord = nestedPayload as Record; + if (typeof nestedRecord.type === 'string') { + return { + directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global', + payload: nestedRecord as Event, + }; + } + } + + return null; + } + + private emitGlobalSseEvent(event: RoutedOpencodeEvent) { + for (const listener of this.globalSseListeners) { + try { + listener(event); + } catch (error) { + console.warn('[OpencodeClient] Global SSE listener error:', error); + } + } + } + + private notifyGlobalSseOpen() { + for (const handler of this.globalSseOpenListeners) { + try { + handler(); + } catch (error) { + console.warn('[OpencodeClient] Global SSE open handler error:', error); + } + } + } + + private notifyGlobalSseError(error: unknown) { + for (const handler of this.globalSseErrorListeners) { + try { + handler(error); + } catch (listenerError) { + console.warn('[OpencodeClient] Global SSE error handler failed:', listenerError); + } + } + } + + private ensureGlobalSseStarted() { + if (this.globalSseTask) { + return; + } + + const abortController = new AbortController(); + this.globalSseAbortController = abortController; + + this.globalSseTask = this.runGlobalSseLoop(abortController) + .catch((error) => { + if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { + return; + } + console.error('[OpencodeClient] Global SSE task failed:', error); + }) + .finally(() => { + if (this.globalSseAbortController === abortController) { + this.globalSseAbortController = null; + } + this.globalSseTask = null; + this.globalSseIsConnected = false; + }); + } + + private maybeStopGlobalSse() { + if (this.globalSseListeners.size > 0) { + return; + } + + if (this.globalSseAbortController && !this.globalSseAbortController.signal.aborted) { + this.globalSseAbortController.abort(); + } + this.globalSseAbortController = null; + } + + private async runGlobalSseLoop(abortController: AbortController): Promise { + const globalEndpoint = `${this.baseUrl.replace(/\/+$/, '')}/global/event`; + let attempt = 0; + + while (!abortController.signal.aborted) { + try { + const headers: Record = { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + }; + if (this.globalSseLastEventId) { + headers['Last-Event-ID'] = this.globalSseLastEventId; + } + + const response = await fetch(globalEndpoint, { + method: 'GET', + headers, + signal: abortController.signal, + }); + + if (!response.ok || !response.body) { + throw new Error(`Global SSE connect failed with status ${response.status}`); + } + + attempt = 0; + this.globalSseIsConnected = true; + if (!abortController.signal.aborted) { + this.notifyGlobalSseOpen(); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (abortController.signal.aborted) break; + if (!value || value.length === 0) continue; + + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); + const blocks = buffer.split('\n\n'); + buffer = blocks.pop() ?? ''; + + for (const block of blocks) { + const parsed = this.parseSseBlock(block); + if (!parsed) continue; + if (parsed.id) { + this.globalSseLastEventId = parsed.id; + } + + const routed = this.normalizeRoutedSsePayload(parsed.data); + if (routed) { + this.emitGlobalSseEvent(routed); + } + } + } + + const remaining = buffer.trim(); + if (remaining && !abortController.signal.aborted) { + const parsed = this.parseSseBlock(remaining); + if (parsed?.id) { + this.globalSseLastEventId = parsed.id; + } + const routed = parsed ? this.normalizeRoutedSsePayload(parsed.data) : null; + if (routed) { + this.emitGlobalSseEvent(routed); + } + } + + // Stream ended; force reconnect. + this.globalSseIsConnected = false; + } catch (error: unknown) { + this.globalSseIsConnected = false; + if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { + return; + } + console.error('[OpencodeClient] Global SSE stream error (will retry):', error); + this.notifyGlobalSseError(error); + } + + if (abortController.signal.aborted) { + break; + } + + attempt += 1; + const delay = Math.min(3000 * Math.pow(2, attempt), 30000); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + subscribeToGlobalEvents( + onEvent: (event: RoutedOpencodeEvent) => void, + onError?: (error: unknown) => void, + onOpen?: () => void, + options?: { directory?: string | null } + ): () => void { + const directoryFilter = this.normalizeCandidatePath(options?.directory ?? null); + const listener = (event: RoutedOpencodeEvent) => { + if (directoryFilter && event.directory !== directoryFilter) { + return; + } + onEvent(event); + }; + + this.globalSseListeners.add(listener); + + if (onOpen) { + this.globalSseOpenListeners.add(onOpen); + if (this.globalSseIsConnected) { + setTimeout(() => { + if (this.globalSseOpenListeners.has(onOpen)) { + try { + onOpen(); + } catch (error) { + console.warn('[OpencodeClient] Global SSE open handler error:', error); + } + } + }, 0); + } + } + + if (onError) { + this.globalSseErrorListeners.add(onError); + } + + this.ensureGlobalSseStarted(); + + return () => { + this.globalSseListeners.delete(listener); + if (onOpen) { + this.globalSseOpenListeners.delete(onOpen); + } + if (onError) { + this.globalSseErrorListeners.delete(onError); + } + this.maybeStopGlobalSse(); + }; + } + // Event Streaming using SDK SSE (Server-Sent Events) with AsyncGenerator subscribeToEvents( onMessage: (event: { type: string; properties?: Record }) => void, @@ -839,6 +1158,7 @@ class OpencodeService { options?: { scope?: 'global' | 'directory'; key?: string } ): () => void { const subscriptionKey = options?.key ?? 'default'; + const scope = options?.scope ?? 'directory'; const existingController = this.sseAbortControllers.get(subscriptionKey); if (existingController) { existingController.abort(); @@ -850,17 +1170,122 @@ class OpencodeService { let lastEventId: string | undefined; + if (scope === 'global') { + let globalUnsub: (() => void) | null = null; + + const attachDirectory = (event: RoutedOpencodeEvent): Event => { + if (event.directory === 'global') { + return event.payload; + } + + const payloadRecord = event.payload as unknown as Record; + const existingProperties = + typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null + ? (payloadRecord.properties as Record) + : {}; + + if (existingProperties.directory === event.directory) { + return event.payload; + } + + return { + ...payloadRecord, + properties: { + ...existingProperties, + directory: event.directory, + }, + } as Event; + }; + + const cleanup = () => { + if (globalUnsub) { + try { + globalUnsub(); + } catch { + // ignore + } + globalUnsub = null; + } + + if (this.sseAbortControllers.get(subscriptionKey) === abortController) { + this.sseAbortControllers.delete(subscriptionKey); + } + }; + + abortController.signal.addEventListener('abort', cleanup, { once: true }); + + globalUnsub = this.subscribeToGlobalEvents( + (event) => { + if (abortController.signal.aborted) { + return; + } + onMessage(attachDirectory(event)); + }, + onError + ? (error) => { + if (!abortController.signal.aborted) { + onError(error); + } + } + : undefined, + onOpen + ? () => { + if (!abortController.signal.aborted) { + onOpen(); + } + } + : undefined, + ); + + return () => { + cleanup(); + abortController.abort(); + }; + } + + const normalizeEventPayload = (payload: unknown): Event | null => { + if (!payload || typeof payload !== 'object') { + return null; + } + + const record = payload as Record; + if (typeof record.type === 'string') { + return record as Event; + } + + const nestedPayload = record.payload; + if (nestedPayload && typeof nestedPayload === 'object') { + const nestedRecord = nestedPayload as Record; + if (typeof nestedRecord.type === 'string') { + if (typeof record.directory === 'string' && record.directory.length > 0) { + const existingProperties = + typeof nestedRecord.properties === 'object' && nestedRecord.properties !== null + ? (nestedRecord.properties as Record) + : null; + const properties = { + ...(existingProperties ?? {}), + directory: record.directory, + }; + return { ...nestedRecord, properties } as Event; + } + return nestedRecord as Event; + } + } + + return null; + }; + + console.log('[OpencodeClient] Starting SSE subscription...'); // Start async generator in background with reconnect on failure (async () => { const resolvedDirectory = - options?.scope === 'global' - ? undefined - : typeof directoryOverride === 'string' && directoryOverride.trim().length > 0 - ? directoryOverride.trim() - : this.currentDirectory; - console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'global'); + typeof directoryOverride === 'string' && directoryOverride.trim().length > 0 + ? directoryOverride.trim() + : this.currentDirectory; + + console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'default'); const connect = async (attempt: number): Promise => { try { @@ -891,8 +1316,9 @@ class OpencodeService { lastEventId = event.id; } const payload = event.data; - if (payload && typeof payload === 'object') { - onMessage(payload as Event); + const normalized = normalizeEventPayload(payload); + if (normalized) { + onMessage(normalized); } }, }; @@ -915,13 +1341,6 @@ class OpencodeService { break; } } - - if (!abortController.signal.aborted) { - // Stream ended unexpectedly; attempt reconnect - const delay = Math.min(3000 * Math.pow(2, attempt), 30000); - await new Promise((resolve) => setTimeout(resolve, delay)); - await connect(attempt + 1); - } } catch (error: unknown) { if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { console.log('[OpencodeClient] SSE stream aborted normally'); @@ -936,6 +1355,13 @@ class OpencodeService { if (!abortController.signal.aborted) { await connect(attempt + 1); } + return; + } + + if (!abortController.signal.aborted) { + const delay = Math.min(3000 * Math.pow(2, attempt), 30000); + await new Promise((resolve) => setTimeout(resolve, delay)); + await connect(attempt + 1); } }; @@ -956,6 +1382,7 @@ class OpencodeService { } abortController.abort(); }; + } // File Operations @@ -1092,7 +1519,7 @@ class OpencodeService { const healthData = await response.json(); - // Check if OpenCode is actually ready (not just OpenChamber server) + // Check if the upstream API is ready (not just OpenChamber server) if (healthData.isOpenCodeReady === false) { return false; } diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index f893c53e..f6d810ad 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -32,6 +32,16 @@ const persistToLocalStorage = (settings: DesktopSettings) => { localStorage.setItem('homeDirectory', settings.homeDirectory); window.__OPENCHAMBER_HOME__ = settings.homeDirectory; } + if (Array.isArray(settings.projects) && settings.projects.length > 0) { + localStorage.setItem('projects', JSON.stringify(settings.projects)); + } else { + localStorage.removeItem('projects'); + } + if (settings.activeProjectId) { + localStorage.setItem('activeProjectId', settings.activeProjectId); + } else { + localStorage.removeItem('activeProjectId'); + } if (Array.isArray(settings.pinnedDirectories) && settings.pinnedDirectories.length > 0) { localStorage.setItem('pinnedDirectories', JSON.stringify(settings.pinnedDirectories)); } else { @@ -78,6 +88,55 @@ const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs'] return result; }; +const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const result: NonNullable = []; + const seenIds = new Set(); + const seenPaths = new Set(); + + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const candidate = entry as Record; + + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; + if (!id || !rawPath) continue; + + const normalizedPath = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, ''); + if (!normalizedPath) continue; + + if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue; + seenIds.add(id); + seenPaths.add(normalizedPath); + + const project: NonNullable[number] = { + id, + path: normalizedPath, + }; + + if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) { + project.label = candidate.label.trim(); + } + if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) { + project.addedAt = candidate.addedAt; + } + if ( + typeof candidate.lastOpenedAt === 'number' && + Number.isFinite(candidate.lastOpenedAt) && + candidate.lastOpenedAt >= 0 + ) { + project.lastOpenedAt = candidate.lastOpenedAt; + } + + result.push(project); + } + + return result.length > 0 ? result : undefined; +}; + const getPersistApi = (): PersistApi | undefined => { const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist; if (candidate && typeof candidate === 'object') { @@ -138,6 +197,15 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { result.homeDirectory = candidate.homeDirectory; } + + const projects = sanitizeProjects(candidate.projects); + if (projects) { + result.projects = projects; + } + if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) { + result.activeProjectId = candidate.activeProjectId; + } + if (Array.isArray(candidate.approvedDirectories)) { result.approvedDirectories = candidate.approvedDirectories.filter( (entry): entry is string => typeof entry === 'string' && entry.length > 0 diff --git a/packages/ui/src/lib/sessionEvents.ts b/packages/ui/src/lib/sessionEvents.ts index 32c5fc56..b09a659f 100644 --- a/packages/ui/src/lib/sessionEvents.ts +++ b/packages/ui/src/lib/sessionEvents.ts @@ -11,6 +11,7 @@ export type SessionDeleteRequest = { export type SessionCreateRequest = { worktreeMode?: 'main' | 'create' | 'reuse'; parentID?: string | null; + projectId?: string | null; }; type DeleteListener = (request: SessionDeleteRequest) => void; diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index 89f65556..4ffa04db 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -280,16 +280,8 @@ const resolveSessionDirectory = async (sessionId: string | null | undefined): Pr try { const sessionStore = useSessionStore.getState(); - const metadata = sessionStore.getWorktreeMetadata(sessionId); - if (metadata?.path) { - return metadata.path; - } - - const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { directory?: string } | undefined; - const sessionDirectory = - typeof session?.directory === 'string' && session.directory.length > 0 ? session.directory : undefined; - - return sessionDirectory; + const directory = sessionStore.getDirectoryForSession(sessionId); + return directory ?? undefined; } catch (error) { console.warn('Failed to resolve session directory override:', error); return undefined; diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index cee10c22..8bad9db2 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -1,19 +1,19 @@ import { create } from "zustand"; import { devtools, persist, createJSONStorage } from "zustand/middleware"; import { opencodeClient } from "@/lib/opencode/client"; -import type { Permission, PermissionResponse } from "@/types/permission"; +import type { PermissionRequest, PermissionResponse } from "@/types/permission"; import { isEditPermissionType, getAgentDefaultEditPermission } from "./utils/permissionUtils"; import { getSafeStorage } from "./utils/safeStorage"; import { useMessageStore } from "./messageStore"; import { useSessionStore } from "./sessionStore"; interface PermissionState { - permissions: Map; + permissions: Map; } interface PermissionActions { - addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map, sessionAgentSelections?: Map, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void; - respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise; + addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map, sessionAgentSelections?: Map, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void; + respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise; } type PermissionStore = PermissionState & PermissionActions; @@ -21,11 +21,11 @@ type PermissionStore = PermissionState & PermissionActions; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; -const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]> => { +const sanitizePermissionEntries = (value: unknown): Array<[string, PermissionRequest[]]> => { if (!Array.isArray(value)) { return []; } - const entries: Array<[string, Permission[]]> = []; + const entries: Array<[string, PermissionRequest[]]> = []; value.forEach((entry) => { if (!Array.isArray(entry) || entry.length !== 2) { return; @@ -34,7 +34,7 @@ const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]] if (typeof sessionId !== "string" || !Array.isArray(permissions)) { return; } - entries.push([sessionId, permissions as Permission[]]); + entries.push([sessionId, permissions as PermissionRequest[]]); }); return entries; }; @@ -42,15 +42,7 @@ const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]] const executeWithPermissionDirectory = async (sessionId: string, operation: () => Promise): Promise => { try { const sessionStore = useSessionStore.getState(); - const metadata = sessionStore.getWorktreeMetadata(sessionId); - if (metadata?.path) { - return opencodeClient.withDirectory(metadata.path, operation); - } - - const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { directory?: string } | undefined; - const directory = - typeof session?.directory === 'string' && session.directory.length > 0 ? session.directory : undefined; - + const directory = sessionStore.getDirectoryForSession(sessionId); if (directory) { return opencodeClient.withDirectory(directory, operation); } @@ -67,13 +59,18 @@ export const usePermissionStore = create()( permissions: new Map(), - addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map, sessionAgentSelections?: Map, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => { + addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map, sessionAgentSelections?: Map, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => { const sessionId = permission.sessionID; if (!sessionId) { return; } - const permissionType = permission.type?.toLowerCase?.() ?? null; + const existing = get().permissions.get(sessionId); + if (existing?.some((entry) => entry.id === permission.id)) { + return; + } + + const permissionType = permission.permission?.toLowerCase?.() ?? null; let agentName = contextData?.currentAgentContext?.get(sessionId); if (!agentName) { @@ -109,8 +106,8 @@ export const usePermissionStore = create()( }); }, - respondToPermission: async (sessionId: string, permissionId: string, response: PermissionResponse) => { - await executeWithPermissionDirectory(sessionId, () => opencodeClient.respondToPermission(sessionId, permissionId, response)); + respondToPermission: async (sessionId: string, requestId: string, response: PermissionResponse) => { + await executeWithPermissionDirectory(sessionId, () => opencodeClient.replyToPermission(requestId, response)); if (response === 'reject') { const messageStore = useMessageStore.getState(); @@ -120,7 +117,7 @@ export const usePermissionStore = create()( set((state) => { const sessionPermissions = state.permissions.get(sessionId) || []; - const updatedPermissions = sessionPermissions.filter((p) => p.id !== permissionId); + const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId); const newPermissions = new Map(state.permissions); newPermissions.set(sessionId, updatedPermissions); return { permissions: newPermissions }; diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts index a79eaf89..95582c19 100644 --- a/packages/ui/src/stores/sessionStore.ts +++ b/packages/ui/src/stores/sessionStore.ts @@ -6,10 +6,14 @@ import { getSafeStorage } from "./utils/safeStorage"; import type { WorktreeMetadata } from "@/types/worktree"; import { archiveWorktree, getWorktreeStatus, listWorktrees, mapWorktreeToMetadata } from "@/lib/git/worktreeService"; import { useDirectoryStore } from "./useDirectoryStore"; +import { useProjectsStore } from "./useProjectsStore"; +import type { ProjectEntry } from "@/lib/api/types"; import { checkIsGitRepository } from "@/lib/gitApi"; +import { streamDebugEnabled } from "@/stores/utils/streamDebug"; interface SessionState { sessions: Session[]; + sessionsByDirectory: Map; currentSessionId: string | null; lastLoadedDirectory: string | null; isLoading: boolean; @@ -17,6 +21,7 @@ interface SessionState { webUICreatedSessions: Set; worktreeMetadata: Map; availableWorktrees: WorktreeMetadata[]; + availableWorktreesByProject: Map; } interface SessionActions { @@ -30,6 +35,7 @@ interface SessionActions { setCurrentSession: (id: string | null) => void; clearError: () => void; getSessionsByDirectory: (directory: string) => Session[]; + getDirectoryForSession: (sessionId: string) => string | null; applySessionMetadata: (sessionId: string, metadata: Partial) => void; isOpenChamberCreatedSession: (sessionId: string) => boolean; markSessionAsOpenChamberCreated: (sessionId: string) => void; @@ -38,6 +44,7 @@ interface SessionActions { getWorktreeMetadata: (sessionId: string) => WorktreeMetadata | undefined; setSessionDirectory: (sessionId: string, directory: string | null) => void; updateSession: (session: Session) => void; + removeSessionFromStore: (sessionId: string) => void; } type SessionStore = SessionState & SessionActions; @@ -153,6 +160,76 @@ const normalizePath = (value?: string | null): string | null => { return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced; }; +const readVSCodeWorkspaceDirectory = (): string | null => { + if (typeof window === "undefined") { + return null; + } + const config = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__; + const workspaceFolder = typeof config?.workspaceFolder === "string" ? config.workspaceFolder : null; + return normalizePath(workspaceFolder); +}; + +const isVSCodeRuntime = (): boolean => { + if (typeof window === "undefined") return false; + const runtime = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }) + .__OPENCHAMBER_RUNTIME_APIS__?.runtime; + return Boolean(runtime?.isVSCode); +}; + +const vscodeDebugLog = (...args: unknown[]) => { + if (!streamDebugEnabled()) return; + if (!isVSCodeRuntime()) return; + console.log("[OpenChamber][VSCode][sessions]", ...args); +}; + +const dedupeSessionsById = (sessions: Session[]): Session[] => { + const map = new Map(); + + sessions.forEach((session) => { + if (!session || typeof session.id !== "string" || session.id.length === 0) { + return; + } + + const existing = map.get(session.id); + if (!existing) { + map.set(session.id, session); + return; + } + + const existingUpdated = (existing as { time?: { updated?: number | null } }).time?.updated ?? 0; + const candidateUpdated = (session as { time?: { updated?: number | null } }).time?.updated ?? 0; + if (candidateUpdated > existingUpdated) { + map.set(session.id, session); + } + }); + + return Array.from(map.values()); +}; + +const buildSessionsByDirectory = (sessions: Session[]): Map => { + const map = new Map(); + + sessions.forEach((session) => { + const directory = normalizePath((session as { directory?: string | null }).directory ?? null); + if (!directory) { + return; + } + + const existing = map.get(directory); + if (existing) { + existing.push(session); + } else { + map.set(directory, [session]); + } + }); + + for (const [key, value] of map.entries()) { + map.set(key, dedupeSessionsById(value)); + } + + return map; +}; + const getSessionDirectory = (sessions: Session[], sessionId: string): string | null => { const target = sessions.find((session) => session.id === sessionId); if (!target) { @@ -238,6 +315,7 @@ export const useSessionStore = create()( (set, get) => ({ sessions: [], + sessionsByDirectory: new Map(), currentSessionId: null, lastLoadedDirectory: null, isLoading: false, @@ -245,212 +323,390 @@ export const useSessionStore = create()( webUICreatedSessions: new Set(), worktreeMetadata: new Map(), availableWorktrees: [], + availableWorktreesByProject: new Map(), loadSessions: async () => { set({ isLoading: true, error: null }); try { const directoryStore = useDirectoryStore.getState(); - const projectDirectory = directoryStore.currentDirectory ?? opencodeClient.getDirectory() ?? null; + const projectsStore = useProjectsStore.getState(); const apiClient = opencodeClient.getApiClient(); + const vscodeWorkspaceDirectory = readVSCodeWorkspaceDirectory(); + const includeDescendants = Boolean(vscodeWorkspaceDirectory); - const fetchSessions = async (directoryParam?: string | null): Promise => { - const response = await apiClient.session.list( - directoryParam ? { directory: directoryParam } : undefined - ); - return Array.isArray(response.data) ? response.data : []; - }; + vscodeDebugLog("loadSessions:start", { + workspace: vscodeWorkspaceDirectory, + currentDirectory: directoryStore.currentDirectory, + clientDirectory: opencodeClient.getDirectory(), + projectsCount: projectsStore.projects.length, + activeProjectId: projectsStore.activeProjectId, + }); - const normalizedProject = normalizePath(projectDirectory); + const canonicalDirectoryCache = new Map(); - const isGitRepo = normalizedProject ? await checkIsGitRepository(normalizedProject).catch(() => false) : false; + const resolveCanonicalDirectory = async (directory: string): Promise => { + const normalizedRequested = normalizePath(directory) ?? directory; + const cacheKey = normalizedRequested; + const cached = canonicalDirectoryCache.get(cacheKey); + if (cached) { + return cached; + } - const parentSessions = await fetchSessions(normalizedProject || null); - - const subdirectorySessions: Session[] = []; - let discoveredWorktrees: WorktreeMetadata[] = []; - - if (projectDirectory && isGitRepo && normalizedProject) { - const worktreeRoot = `${normalizedProject}/${WORKTREE_ROOT}`; try { - const candidates = new Set(); - - // Check if .openchamber directory exists before trying to list it - const projectEntries = await opencodeClient.listLocalDirectory(normalizedProject); - const worktreeDirExists = projectEntries.some( - (entry) => entry.isDirectory && entry.name === WORKTREE_ROOT - ); - - if (worktreeDirExists) { - const entries = await opencodeClient.listLocalDirectory(worktreeRoot); - entries - .filter((entry) => entry.isDirectory) - .forEach((entry) => { - const isAbsolutePath = /^([A-Za-z]:)?\//.test(entry.path); - const resolvedPath = isAbsolutePath ? entry.path : `${worktreeRoot}/${entry.name}`; - candidates.add(normalizePath(resolvedPath) ?? resolvedPath); - }); - } - - const listedWorktrees = await listWorktrees(normalizedProject); - if (Array.isArray(listedWorktrees)) { - discoveredWorktrees = listedWorktrees - .map((info) => mapWorktreeToMetadata(normalizedProject, info)) - .filter((meta) => meta.path.includes(`/${WORKTREE_ROOT}/`)); - discoveredWorktrees.forEach((meta) => candidates.add(meta.path)); - } - - if (candidates.size > 0) { - const results = await Promise.allSettled( - Array.from(candidates).map((path) => fetchSessions(path)) - ); - results.forEach((result) => { - if (result.status === "fulfilled" && Array.isArray(result.value)) { - subdirectorySessions.push(...result.value); - } - }); - } + const info = await apiClient.path.get({ directory }); + const canonical = normalizePath((info.data as { directory?: string | null } | null)?.directory ?? null); + const resolved = canonical ?? normalizedRequested; + canonicalDirectoryCache.set(cacheKey, resolved); + return resolved; } catch { - - discoveredWorktrees = []; + canonicalDirectoryCache.set(cacheKey, normalizedRequested); + return normalizedRequested; } - } - - const validPaths = new Set(); - if (normalizedProject) { - validPaths.add(normalizedProject); - } - discoveredWorktrees.forEach((meta) => { - const normalized = normalizePath(meta.path); - if (normalized) { - validPaths.add(normalized); - } - }); - - const mergedSessions = [...parentSessions, ...subdirectorySessions].filter((session) => { - const rawDir = (session as { directory?: string | null }).directory ?? normalizedProject ?? null; - const normalizedDir = normalizePath(rawDir); - if (!normalizedDir) { - return false; - } - return validPaths.has(normalizedDir); - }); - const stateSnapshot = get(); - - const previousDirectory = stateSnapshot.lastLoadedDirectory ?? null; - const directoryChanged = projectDirectory !== previousDirectory; - - let nextSessions = [...mergedSessions]; - let nextCurrentId = stateSnapshot.currentSessionId; - - const ensureSessionPresent = (session: Session) => { - nextSessions = [session, ...nextSessions.filter((item) => item.id !== session.id)]; }; - if (directoryChanged) { - nextCurrentId = nextSessions.length > 0 ? nextSessions[0].id : null; - } else { - if (nextCurrentId) { - const hasCurrent = nextSessions.some((session) => session.id === nextCurrentId); - if (!hasCurrent) { - const persistedSession = stateSnapshot.sessions.find((session) => session.id === nextCurrentId); + const filterSessionsToDirectory = ( + sessions: Session[], + directory: string, + options?: { includeDescendants?: boolean; includeMissingDirectory?: boolean } + ): Session[] => { + const normalized = normalizePath(directory); + if (!normalized) { + return sessions; + } + const includeDescendants = options?.includeDescendants === true; + const prefix = includeDescendants ? `${normalized}/` : null; + const includeMissingDirectory = options?.includeMissingDirectory === true; + return sessions.filter((session) => { + const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null); + if (!sessionDir) return includeMissingDirectory; + if (sessionDir === normalized) return true; + if (prefix && sessionDir.startsWith(prefix)) return true; + return false; + }); + }; - if (persistedSession) { - ensureSessionPresent(persistedSession); - } else { - try { - const resolvedSession = await opencodeClient.getSession(nextCurrentId); - ensureSessionPresent(resolvedSession); - } catch { - nextCurrentId = nextSessions.length > 0 ? nextSessions[0].id : null; + const assignRequestedDirectory = ( + sessions: Session[], + requestedDirectory: string, + canonicalDirectory?: string | null + ): Session[] => { + const normalizedRequested = normalizePath(requestedDirectory); + if (!normalizedRequested) { + return sessions; + } + const normalizedCanonical = normalizePath(canonicalDirectory ?? null); + if (!normalizedCanonical || normalizedCanonical === normalizedRequested) { + return sessions.map((session) => { + const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null); + if (sessionDir) { + return session; + } + return ({ ...session, directory: normalizedRequested } as Session); + }); + } + + const canonicalPrefix = normalizedCanonical === "/" ? "/" : `${normalizedCanonical}/`; + const requestedPrefix = normalizedRequested === "/" ? "/" : `${normalizedRequested}/`; + + return sessions.map((session) => { + const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null); + if (!sessionDir) { + return ({ ...session, directory: normalizedRequested } as Session); + } + if (sessionDir === normalizedCanonical) { + return ({ ...session, directory: normalizedRequested } as Session); + } + if (canonicalPrefix !== "/" && sessionDir.startsWith(canonicalPrefix)) { + const suffix = sessionDir.slice(canonicalPrefix.length); + return ({ ...session, directory: `${requestedPrefix}${suffix}` } as Session); + } + return session; + }); + }; + + const fetchSessionsForDirectory = async (directoryParam?: string | null): Promise => { + const requestedDirectory = normalizePath(directoryParam); + if (!requestedDirectory) { + try { + const response = await apiClient.session.list(undefined); + return Array.isArray(response.data) ? response.data : []; + } catch (error) { + console.debug("Failed to list sessions (global):", error); + throw error; + } + } + + const canonicalDirectory = await resolveCanonicalDirectory(requestedDirectory); + + const listFromDirectoryScopedCall = async (): Promise => { + const response = await apiClient.session.list({ directory: requestedDirectory }); + return Array.isArray(response.data) ? response.data : []; + }; + + let sessions: Session[] = []; + let listError: unknown = null; + let usedGlobalFallback = false; + try { + sessions = await listFromDirectoryScopedCall(); + } catch (error) { + console.debug("Failed to list sessions for directory:", requestedDirectory, error); + listError = error; + sessions = []; + } + + // Some runtimes canonicalize directory paths (e.g. realpath). If the scoped call returns no results, + // fall back to the global list and map canonical paths back to the requested directory. + if (sessions.length === 0) { + usedGlobalFallback = true; + try { + const globalResponse = await apiClient.session.list(undefined); + const globalList = Array.isArray(globalResponse.data) ? globalResponse.data : []; + sessions = filterSessionsToDirectory(globalList, canonicalDirectory, { + includeDescendants, + includeMissingDirectory: false, + }); + } catch (error) { + console.debug("Failed to list sessions (global fallback):", error); + if (listError) { + throw listError; + } + throw error; + } + } + + const filtered = filterSessionsToDirectory(sessions, canonicalDirectory, { + includeDescendants, + includeMissingDirectory: !usedGlobalFallback, + }); + vscodeDebugLog("fetchSessionsForDirectory", { + requestedDirectory, + canonicalDirectory, + fetched: sessions.length, + filtered: filtered.length, + }); + return assignRequestedDirectory(filtered, requestedDirectory, canonicalDirectory); + }; + + const normalizedFallback = normalizePath(directoryStore.currentDirectory ?? opencodeClient.getDirectory() ?? null); + const activeProject = projectsStore.projects.find((project) => project.id === projectsStore.activeProjectId) ?? null; + const activeProjectRoot = normalizePath(activeProject?.path ?? null); + + const legacyRoot = activeProjectRoot ?? normalizedFallback ?? null; + + const projectEntries: Array> = projectsStore.projects.length > 0 + ? projectsStore.projects + : (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []); + + type ProjectSessionResult = { + projectId: string; + projectPath: string | null; + sessions: Session[]; + discoveredWorktrees: WorktreeMetadata[]; + validPaths: Set; + }; + + if (projectEntries.length === 0) { + set({ + sessions: [], + sessionsByDirectory: new Map(), + currentSessionId: null, + lastLoadedDirectory: null, + isLoading: false, + worktreeMetadata: new Map(), + availableWorktrees: [], + availableWorktreesByProject: new Map(), + }); + return; + } + + const projectResults: ProjectSessionResult[] = await Promise.all( + projectEntries.map(async (project: Pick) => { + const normalizedProject = normalizePath(project.path); + if (!normalizedProject) { + return { + projectId: project.id, + projectPath: null, + sessions: [], + discoveredWorktrees: [], + validPaths: new Set(), + }; + } + + const isGitRepo = await checkIsGitRepository(normalizedProject).catch(() => false); + const parentSessions = await fetchSessionsForDirectory(normalizedProject || null); + vscodeDebugLog("projectSessions", { + projectId: project.id, + projectPath: normalizedProject, + isGitRepo, + parentSessions: parentSessions.length, + }); + + const subdirectorySessions: Session[] = []; + let discoveredWorktrees: WorktreeMetadata[] = []; + const validPaths = new Set(); + validPaths.add(normalizedProject); + + if (isGitRepo) { + const worktreeRoot = `${normalizedProject}/${WORKTREE_ROOT}`; + try { + const candidates = new Set(); + + // Check if .openchamber directory exists before trying to list it + const projectEntriesList = await opencodeClient.listLocalDirectory(normalizedProject); + const worktreeDirExists = projectEntriesList.some( + (entry) => entry.isDirectory && entry.name === WORKTREE_ROOT + ); + + if (worktreeDirExists) { + const entries = await opencodeClient.listLocalDirectory(worktreeRoot); + entries + .filter((entry) => entry.isDirectory) + .forEach((entry) => { + const isAbsolutePath = /^([A-Za-z]:)?\//.test(entry.path); + const resolvedPath = isAbsolutePath ? entry.path : `${worktreeRoot}/${entry.name}`; + const normalizedPath = normalizePath(resolvedPath) ?? resolvedPath; + candidates.add(normalizedPath); + }); } + + const listedWorktrees = await listWorktrees(normalizedProject); + if (Array.isArray(listedWorktrees)) { + discoveredWorktrees = listedWorktrees + .map((info) => mapWorktreeToMetadata(normalizedProject, info)) + .filter((meta) => meta.path.includes(`/${WORKTREE_ROOT}/`)); + discoveredWorktrees.forEach((meta) => candidates.add(meta.path)); + } + + candidates.forEach((candidate) => { + const normalizedCandidate = normalizePath(candidate) ?? candidate; + validPaths.add(normalizedCandidate); + }); + + if (candidates.size > 0) { + const results = await Promise.allSettled( + Array.from(candidates).map((path) => fetchSessionsForDirectory(path)) + ); + results.forEach((result) => { + if (result.status === "fulfilled" && Array.isArray(result.value)) { + subdirectorySessions.push(...result.value); + } + }); + } + } catch { + discoveredWorktrees = []; } } - } else { - nextCurrentId = nextSessions.length > 0 ? nextSessions[0].id : null; + + const mergedSessions = dedupeSessionsById([...parentSessions, ...subdirectorySessions]); + + return { + projectId: project.id, + projectPath: normalizedProject, + sessions: mergedSessions, + discoveredWorktrees, + validPaths, + }; + }) + ); + + const sessionsByDirectory = new Map(); + projectResults.forEach((result) => { + if (!result.projectPath) { + return; + } + + result.validPaths.forEach((directory) => { + const directoryKey = normalizePath(directory) ?? directory; + const directorySessions = result.sessions.filter((session) => { + const dir = normalizePath((session as { directory?: string | null }).directory ?? null) ?? directoryKey; + return dir === directoryKey; + }); + sessionsByDirectory.set(directoryKey, dedupeSessionsById(directorySessions)); + }); + }); + + const mergedSessions: Session[] = dedupeSessionsById(Array.from(sessionsByDirectory.values()).flat()); + const stateSnapshot = get(); + + let nextWorktreeMetadata = stateSnapshot.worktreeMetadata; + for (const result of projectResults) { + if (!result.projectPath) { + continue; + } + try { + const hydratedMetadata = await hydrateSessionWorktreeMetadata( + result.sessions, + result.projectPath, + nextWorktreeMetadata + ); + if (hydratedMetadata) { + nextWorktreeMetadata = hydratedMetadata; + } + } catch (metadataError) { + console.debug("Failed to refresh worktree metadata during session load:", metadataError); } } - const dedupedSessions = nextSessions.reduce((accumulator, session) => { - if (!accumulator.some((existing) => existing.id === session.id)) { - accumulator.push(session); + const worktreesByProject = new Map(); + projectResults.forEach((result) => { + if (result.projectPath) { + worktreesByProject.set(result.projectPath, result.discoveredWorktrees); } - return accumulator; - }, []); + }); - if (nextCurrentId && !dedupedSessions.some((session) => session.id === nextCurrentId)) { - nextCurrentId = dedupedSessions.length > 0 ? dedupedSessions[0].id : null; + const allValidPaths = new Set(); + projectResults.forEach((result) => { + result.validPaths.forEach((value) => { + const key = normalizePath(value) ?? value; + if (key) { + allValidPaths.add(key); + } + }); + }); + + const activeDirectoryCandidate = normalizedFallback ?? activeProjectRoot ?? null; + const activeDirectory = activeDirectoryCandidate && allValidPaths.has(activeDirectoryCandidate) + ? activeDirectoryCandidate + : (activeProjectRoot ?? activeDirectoryCandidate); + + const activeDirectorySessions = activeDirectory + ? sessionsByDirectory.get(activeDirectory) ?? [] + : mergedSessions; + + const validSessionIds = new Set(mergedSessions.map((session) => session.id)); + + // Keep directory-scoped stored selections tidy. + for (const [directoryKey, directorySessions] of sessionsByDirectory.entries()) { + clearInvalidSessionSelection(directoryKey, directorySessions.map((session) => session.id)); } - const validSessionIds = new Set(dedupedSessions.map((session) => session.id)); + const directoryChanged = (activeDirectory ?? null) !== (stateSnapshot.lastLoadedDirectory ?? null); - const resolveSelectionDirectory = (sessionId: string | null): string | null => { - if (!sessionId) { - return null; - } - const sessionDir = getSessionDirectory(dedupedSessions, sessionId); - if (sessionDir) { - return sessionDir; - } - const persistedDir = getSessionDirectory(stateSnapshot.sessions, sessionId); - if (persistedDir) { - return persistedDir; - } - return null; - }; - - const selectionDirectoryKey = resolveSelectionDirectory(nextCurrentId) ?? normalizedProject ?? projectDirectory ?? null; - - if (projectDirectory) { - clearInvalidSessionSelection(projectDirectory, validSessionIds); + let nextCurrentId = stateSnapshot.currentSessionId; + if (!nextCurrentId || !validSessionIds.has(nextCurrentId) || directoryChanged) { + nextCurrentId = activeDirectorySessions[0]?.id ?? mergedSessions[0]?.id ?? null; } - if (selectionDirectoryKey) { - clearInvalidSessionSelection(selectionDirectoryKey, validSessionIds); - const storedSelection = getStoredSessionForDirectory(selectionDirectoryKey); + if (activeDirectory) { + const storedSelection = getStoredSessionForDirectory(activeDirectory); if (storedSelection && validSessionIds.has(storedSelection)) { nextCurrentId = storedSelection; } } - let hydratedMetadata: Map | null = null; - try { - hydratedMetadata = await hydrateSessionWorktreeMetadata( - dedupedSessions, - projectDirectory, - stateSnapshot.worktreeMetadata - ); - } catch (metadataError) { - console.debug("Failed to refresh worktree metadata during session load:", metadataError); - } - - const nextWorktreeMetadata = (() => { - const source = hydratedMetadata ?? stateSnapshot.worktreeMetadata; - if (!directoryChanged || !normalizedProject) { - return source; - } - - const filtered = new Map(); - source.forEach((meta, key) => { - if (normalizePath(meta.projectDirectory) === normalizedProject) { - filtered.set(key, meta); - } - }); - return filtered; - })(); - const resolvedDirectoryForCurrent = (() => { if (!nextCurrentId) { - return normalizedProject ?? null; + return activeDirectory ?? null; } const metadataPath = nextWorktreeMetadata.get(nextCurrentId)?.path; if (metadataPath) { return normalizePath(metadataPath) ?? metadataPath; } - const sessionDir = getSessionDirectory(dedupedSessions, nextCurrentId); + const sessionDir = getSessionDirectory(mergedSessions, nextCurrentId); if (sessionDir) { return sessionDir; } - return normalizedProject ?? null; + return activeDirectory ?? null; })(); try { @@ -459,16 +715,27 @@ export const useSessionStore = create()( console.warn("Failed to sync OpenCode directory after session load:", error); } + const activeWorktrees = activeProjectRoot + ? projectResults.find((result) => result.projectPath === activeProjectRoot)?.discoveredWorktrees ?? [] + : []; + set({ - sessions: dedupedSessions, + sessions: mergedSessions, + sessionsByDirectory, currentSessionId: nextCurrentId, - lastLoadedDirectory: projectDirectory, + lastLoadedDirectory: activeDirectory ?? null, isLoading: false, worktreeMetadata: nextWorktreeMetadata, - availableWorktrees: discoveredWorktrees, + availableWorktrees: activeWorktrees, + availableWorktreesByProject: worktreesByProject, }); - storeSessionForDirectory(resolvedDirectoryForCurrent ?? projectDirectory, nextCurrentId); + if (activeDirectory) { + storeSessionForDirectory(activeDirectory, nextCurrentId); + } + if (resolvedDirectoryForCurrent && resolvedDirectoryForCurrent !== activeDirectory) { + storeSessionForDirectory(resolvedDirectoryForCurrent, nextCurrentId); + } } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to load sessions", @@ -481,7 +748,9 @@ export const useSessionStore = create()( set({ error: null }); const directoryStore = useDirectoryStore.getState(); const fallbackDirectory = normalizePath(directoryStore.currentDirectory); - const targetDirectory = normalizePath(directoryOverride ?? opencodeClient.getDirectory() ?? fallbackDirectory); + const vscodeWorkspaceDirectory = readVSCodeWorkspaceDirectory(); + const targetDirectory = vscodeWorkspaceDirectory ?? normalizePath(directoryOverride ?? opencodeClient.getDirectory() ?? fallbackDirectory); + vscodeDebugLog("createSession:start", { title, parentID, targetDirectory, vscodeWorkspaceDirectory }); const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const previousState = get(); @@ -501,12 +770,22 @@ export const useSessionStore = create()( share: undefined, } as Session; - set((state) => ({ - sessions: [optimisticSession, ...state.sessions], - currentSessionId: tempId, - webUICreatedSessions: new Set([...state.webUICreatedSessions, tempId]), - isLoading: false, - })); + set((state) => { + const nextSessions = [optimisticSession, ...state.sessions]; + const nextByDirectory = new Map(state.sessionsByDirectory); + if (targetDirectory) { + const existing = nextByDirectory.get(targetDirectory) ?? []; + nextByDirectory.set(targetDirectory, dedupeSessionsById([optimisticSession, ...existing])); + } + + return { + sessions: nextSessions, + sessionsByDirectory: nextByDirectory, + currentSessionId: tempId, + webUICreatedSessions: new Set([...state.webUICreatedSessions, tempId]), + isLoading: false, + }; + }); if (targetDirectory) { try { @@ -517,18 +796,31 @@ export const useSessionStore = create()( } const replaceOptimistic = (real: Session) => { + const normalizedTarget = targetDirectory ?? null; + const normalizedReal: Session = (normalizedTarget + ? ({ ...real, directory: normalizedTarget } as Session) + : real); set((state) => { - const updatedSessions = state.sessions.map((item) => (item.id === tempId ? real : item)); + const updatedSessions = state.sessions.map((item) => (item.id === tempId ? normalizedReal : item)); + + const nextByDirectory = new Map(state.sessionsByDirectory); + if (targetDirectory) { + const existing = nextByDirectory.get(targetDirectory) ?? []; + const replaced = existing.map((item) => (item.id === tempId ? normalizedReal : item)); + nextByDirectory.set(targetDirectory, dedupeSessionsById(replaced)); + } + return { sessions: updatedSessions, - currentSessionId: real.id, + sessionsByDirectory: buildSessionsByDirectory(updatedSessions), + currentSessionId: normalizedReal.id, webUICreatedSessions: new Set([ ...Array.from(state.webUICreatedSessions).filter((id) => id !== tempId), - real.id, + normalizedReal.id, ]), }; }); - storeSessionForDirectory(targetDirectory ?? null, real.id); + storeSessionForDirectory(targetDirectory ?? null, normalizedReal.id); }; const pollForSession = async (): Promise => { @@ -646,12 +938,23 @@ export const useSessionStore = create()( const nextAvailableWorktrees = options?.archiveWorktree && metadata ? state.availableWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadata.path)) : state.availableWorktrees; + const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject); + if (options?.archiveWorktree && metadata) { + const projectKey = normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory; + const projectWorktrees = nextAvailableWorktreesByProject.get(projectKey) ?? []; + nextAvailableWorktreesByProject.set( + projectKey, + projectWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadata.path)) + ); + } return { sessions: filteredSessions, + sessionsByDirectory: buildSessionsByDirectory(filteredSessions), currentSessionId: nextCurrentId, isLoading: false, worktreeMetadata: nextMetadata, availableWorktrees: nextAvailableWorktrees, + availableWorktreesByProject: nextAvailableWorktreesByProject, }; }); @@ -774,12 +1077,35 @@ export const useSessionStore = create()( ) : state.availableWorktrees; + const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject); + if (removedWorktrees.length > 0) { + const removedPathsByProject = removedWorktrees.reduce>>((accumulator, entry) => { + const projectKey = normalizePath(entry.projectDirectory) ?? entry.projectDirectory; + const pathKey = normalizePath(entry.path) ?? entry.path; + if (!accumulator.has(projectKey)) { + accumulator.set(projectKey, new Set()); + } + accumulator.get(projectKey)?.add(pathKey); + return accumulator; + }, new Map()); + + removedPathsByProject.forEach((paths, projectKey) => { + const projectWorktrees = nextAvailableWorktreesByProject.get(projectKey) ?? []; + const filtered = projectWorktrees.filter( + (entry) => !paths.has(normalizePath(entry.path) ?? entry.path) + ); + nextAvailableWorktreesByProject.set(projectKey, filtered); + }); + } + return { sessions: filteredSessions, + sessionsByDirectory: buildSessionsByDirectory(filteredSessions), currentSessionId: nextCurrentId, ...(silent ? {} : { isLoading: false, error: errorMessage }), worktreeMetadata: nextMetadata, availableWorktrees: nextAvailableWorktrees, + availableWorktreesByProject: nextAvailableWorktreesByProject, }; }); @@ -798,9 +1124,10 @@ export const useSessionStore = create()( const updatedSession = overrideDirectory ? await opencodeClient.withDirectory(overrideDirectory, updateRequest) : await updateRequest(); - set((state) => ({ - sessions: state.sessions.map((s) => (s.id === id ? updatedSession : s)), - })); + set((state) => { + const sessions = state.sessions.map((s) => (s.id === id ? updatedSession : s)); + return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; + }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to update session title", @@ -826,9 +1153,10 @@ export const useSessionStore = create()( : await shareRequest(); if (response.data) { - set((state) => ({ - sessions: state.sessions.map((s) => (s.id === id ? response.data : s)), - })); + set((state) => { + const sessions = state.sessions.map((s) => (s.id === id ? response.data : s)); + return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; + }); return response.data; } return null; @@ -858,9 +1186,10 @@ export const useSessionStore = create()( : await unshareRequest(); if (response.data) { - set((state) => ({ - sessions: state.sessions.map((s) => (s.id === id ? response.data : s)), - })); + set((state) => { + const sessions = state.sessions.map((s) => (s.id === id ? response.data : s)); + return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; + }); return response.data; } return null; @@ -882,9 +1211,34 @@ export const useSessionStore = create()( set({ error: null }); }, - getSessionsByDirectory: () => { - const { sessions } = get(); - return sessions; + getSessionsByDirectory: (directory: string) => { + const normalized = normalizePath(directory) ?? directory; + const { sessionsByDirectory, sessions } = get(); + + const direct = sessionsByDirectory.get(normalized); + if (direct) { + return direct; + } + + return sessions.filter((session) => { + const dir = normalizePath((session as { directory?: string | null }).directory ?? null); + return (dir ?? normalized) === normalized; + }); + }, + + getDirectoryForSession: (sessionId: string) => { + if (!sessionId) { + return null; + } + + const metadata = get().worktreeMetadata.get(sessionId); + if (metadata?.path) { + return normalizePath(metadata.path) ?? metadata.path; + } + + const entry = get().sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined; + const directory = normalizePath(entry?.directory ?? null); + return directory; }, applySessionMetadata: (sessionId, metadata) => { @@ -932,7 +1286,10 @@ export const useSessionStore = create()( const sessions = [...state.sessions]; sessions[index] = hasChanged ? mergedSession : existingSession; - return hasChanged ? ({ sessions } as Partial) : state; + return { + sessions, + sessionsByDirectory: buildSessionsByDirectory(sessions), + }; }); }, @@ -1008,7 +1365,7 @@ export const useSessionStore = create()( delete updatedSession.directory; } sessions[targetIndex] = updatedSession as Session; - return { sessions }; + return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; }); if (previousDirectory) { @@ -1021,9 +1378,51 @@ export const useSessionStore = create()( }, updateSession: (session: Session) => { - set((state) => ({ - sessions: state.sessions.map((s) => (s.id === session.id ? session : s)), - })); + set((state) => { + const index = state.sessions.findIndex((s) => s.id === session.id); + const nextSessions = index === -1 + ? [session, ...state.sessions] + : state.sessions.map((s) => (s.id === session.id ? session : s)); + + const deduped = dedupeSessionsById(nextSessions); + + return { + sessions: deduped, + sessionsByDirectory: buildSessionsByDirectory(deduped), + }; + }); + }, + + removeSessionFromStore: (sessionId: string) => { + if (!sessionId) { + return; + } + + set((state) => { + const target = state.sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined; + const directory = normalizePath(target?.directory ?? null); + + const filteredSessions = state.sessions.filter((session) => session.id !== sessionId); + if (filteredSessions.length === state.sessions.length) { + return state; + } + + const nextMetadata = new Map(state.worktreeMetadata); + nextMetadata.delete(sessionId); + + const nextCurrentId = state.currentSessionId === sessionId ? null : state.currentSessionId; + + if (directory) { + storeSessionForDirectory(directory, null); + } + + return { + sessions: filteredSessions, + sessionsByDirectory: buildSessionsByDirectory(filteredSessions), + currentSessionId: nextCurrentId, + worktreeMetadata: nextMetadata, + }; + }); }, }), { @@ -1036,6 +1435,7 @@ export const useSessionStore = create()( webUICreatedSessions: Array.from(state.webUICreatedSessions), worktreeMetadata: Array.from(state.worktreeMetadata.entries()), availableWorktrees: state.availableWorktrees, + availableWorktreesByProject: Array.from(state.availableWorktreesByProject.entries()), }), merge: (persistedState, currentState) => { const isRecord = (value: unknown): value is Record => @@ -1066,19 +1466,30 @@ export const useSessionStore = create()( ? (persistedState.availableWorktrees as WorktreeMetadata[]) : currentState.availableWorktrees; + const persistedWorktreesByProjectEntries = Array.isArray(persistedState.availableWorktreesByProject) + ? (persistedState.availableWorktreesByProject as Array<[string, WorktreeMetadata[]]>) + : []; + const persistedWorktreesByProject = new Map(persistedWorktreesByProjectEntries); + const lastLoadedDirectory = typeof persistedState.lastLoadedDirectory === "string" ? persistedState.lastLoadedDirectory : currentState.lastLoadedDirectory ?? null; + const mergedSessions = dedupeSessionsById(persistedSessions); + return { ...currentState, ...persistedState, - sessions: persistedSessions, + sessions: mergedSessions, + sessionsByDirectory: buildSessionsByDirectory(mergedSessions), currentSessionId: persistedCurrentSessionId, webUICreatedSessions: new Set(webUiSessionsArray), worktreeMetadata: new Map(persistedWorktreeEntries), availableWorktrees: persistedAvailableWorktrees, + availableWorktreesByProject: persistedWorktreesByProject.size > 0 + ? persistedWorktreesByProject + : currentState.availableWorktreesByProject, lastLoadedDirectory, }; }, diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index e1e1affa..aa21d923 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -1,5 +1,5 @@ import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; -import type { Permission, PermissionResponse } from "@/types/permission"; +import type { PermissionRequest, PermissionResponse } from "@/types/permission"; export interface AttachedFile { id: string; @@ -70,13 +70,14 @@ export type NewSessionDraftState = { export interface SessionStore { sessions: Session[]; + sessionsByDirectory: Map; currentSessionId: string | null; lastLoadedDirectory: string | null; messages: Map; sessionMemoryState: Map; messageStreamStates: Map; sessionCompactionUntil: Map; - permissions: Map; + permissions: Map; sessionAbortFlags: Map; attachedFiles: AttachedFile[]; abortPromptSessionId: string | null; @@ -96,6 +97,7 @@ export interface SessionStore { webUICreatedSessions: Set; worktreeMetadata: Map; availableWorktrees: import('@/types/worktree').WorktreeMetadata[]; + availableWorktreesByProject: Map; currentAgentContext: Map; @@ -139,10 +141,11 @@ export interface SessionStore { markMessageStreamSettled: (messageId: string) => void; updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Message) => void; updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void; - addPermission: (permission: Permission) => void; - respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise; + addPermission: (permission: PermissionRequest) => void; + respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise; clearError: () => void; getSessionsByDirectory: (directory: string) => Session[]; + getDirectoryForSession: (sessionId: string) => string | null; getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null; getCurrentAgent: (sessionId: string) => string | undefined; syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void; @@ -190,6 +193,7 @@ export interface SessionStore { pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => void; updateSession: (session: Session) => void; + removeSessionFromStore: (sessionId: string) => void; revertToMessage: (sessionId: string, messageId: string) => Promise; handleSlashUndo: (sessionId: string) => Promise; diff --git a/packages/ui/src/stores/useAgentGroupsStore.ts b/packages/ui/src/stores/useAgentGroupsStore.ts index 0b26d426..5c516ebc 100644 --- a/packages/ui/src/stores/useAgentGroupsStore.ts +++ b/packages/ui/src/stores/useAgentGroupsStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; import { useDirectoryStore } from './useDirectoryStore'; +import { useProjectsStore } from './useProjectsStore'; import { useSessionStore } from './useSessionStore'; import type { WorktreeMetadata } from '@/types/worktree'; import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService'; @@ -9,6 +10,28 @@ import type { Session } from '@opencode-ai/sdk/v2'; const OPENCHAMBER_DIR = '.openchamber'; +const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => { + const projectsState = useProjectsStore.getState(); + const activeProjectId = projectsState.activeProjectId; + const activeProjectPath = activeProjectId + ? projectsState.projects.find((project) => project.id === activeProjectId)?.path + : undefined; + + if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) { + return activeProjectPath; + } + + const normalizedCurrent = typeof currentDirectory === 'string' ? normalize(currentDirectory) : ''; + const marker = `/${OPENCHAMBER_DIR}/`; + const markerIndex = normalizedCurrent.indexOf(marker); + + if (markerIndex > 0) { + return normalizedCurrent.slice(0, markerIndex); + } + + return currentDirectory ? normalize(currentDirectory) : null; +}; + /** * Agent group session parsed from OpenCode session titles. * Session titles follow pattern: `groupSlug/provider/model` or `groupSlug/provider/model/index` @@ -69,12 +92,16 @@ interface AgentGroupsActions { selectGroup: (groupName: string | null) => void; /** Select a session within the current group */ selectSession: (sessionId: string | null) => void; + /** Delete the entire group (all worktrees + sessions in those worktrees). */ + deleteGroup: (groupName: string) => Promise; + /** Delete a single worktree within a group (and all sessions in that worktree). */ + deleteGroupWorktree: (groupName: string, worktreePath: string) => Promise; + /** Keep one worktree and remove all others in the group. */ + keepOnlyGroupWorktree: (groupName: string, keepWorktreePath: string) => Promise; /** Get the currently selected group */ getSelectedGroup: () => AgentGroup | null; /** Get the currently selected session */ getSelectedSession: () => AgentGroupSession | null; - /** Delete a group and all its sessions, archiving worktrees */ - deleteGroup: (groupName: string) => Promise<{ success: boolean; deletedCount: number; failedCount: number }>; /** Clear error */ clearError: () => void; } @@ -88,6 +115,214 @@ const normalize = (value: string): string => { return replaced.replace(/\/+$/, ''); }; +const buildOpenChamberRoot = (projectDirectory: string): string => { + const normalizedProject = normalize(projectDirectory); + if (!normalizedProject || normalizedProject === '/') { + return `/${OPENCHAMBER_DIR}`; + } + return `${normalizedProject}/${OPENCHAMBER_DIR}`; +}; + +const resolveDirectoryListingPaths = (root: string, entries: Array<{ name?: string; path?: string }>): string[] => { + const normalizedRoot = normalize(root); + return entries + .map((entry) => { + const entryPath = typeof entry.path === 'string' && entry.path.trim().length > 0 ? entry.path : null; + if (entryPath) { + const normalizedEntry = normalize(entryPath); + if (normalizedEntry) { + return normalizedEntry; + } + } + const name = typeof entry.name === 'string' ? entry.name.trim() : ''; + if (!name || !normalizedRoot) { + return null; + } + return `${normalizedRoot}/${name}`; + }) + .filter((value): value is string => Boolean(value)); +}; + +const listOpenChamberDirectories = async (root: string): Promise => { + const normalizedRoot = normalize(root); + if (!normalizedRoot) { + return []; + } + + try { + const entries = await opencodeClient.listLocalDirectory(normalizedRoot); + const directories = entries.filter((entry) => entry.isDirectory); + return resolveDirectoryListingPaths(normalizedRoot, directories); + } catch { + return []; + } +}; + +const startsWithDirectory = (candidate: string, root: string): boolean => { + const normalizedCandidate = normalize(candidate); + const normalizedRoot = normalize(root); + if (!normalizedCandidate || !normalizedRoot) { + return false; + } + if (normalizedCandidate === normalizedRoot) { + return true; + } + const prefix = normalizedRoot === '/' ? '/' : `${normalizedRoot}/`; + return normalizedCandidate.startsWith(prefix); +}; + +const resolveCanonicalDirectory = async ( + apiClient: ReturnType, + directory: string +): Promise => { + const normalized = normalize(directory); + if (!normalized) { + return normalized; + } + try { + const response = await apiClient.path.get({ directory: normalized }); + const canonical = normalize((response.data as { directory?: string | null } | null)?.directory ?? ''); + return canonical || normalized; + } catch { + return normalized; + } +}; + +const listSessionsForDirectory = async ( + apiClient: ReturnType, + directory: string +): Promise => { + const normalized = normalize(directory); + if (!normalized) { + return []; + } + + const canonical = await resolveCanonicalDirectory(apiClient, normalized); + + const filterToDirectory = (sessions: Session[]) => { + return sessions.filter((session) => { + const dir = normalize((session as { directory?: string | null }).directory ?? ''); + if (!dir) return false; + return startsWithDirectory(dir, normalized) || (canonical !== normalized && startsWithDirectory(dir, canonical)); + }); + }; + + const attemptList = async (dir: string) => { + const response = await apiClient.session.list({ directory: dir }); + return Array.isArray(response.data) ? response.data : []; + }; + + try { + const list = filterToDirectory(await attemptList(normalized)); + if (list.length > 0) { + return list; + } + } catch { + // ignore + } + + if (canonical && canonical !== normalized) { + try { + const list = filterToDirectory(await attemptList(canonical)); + if (list.length > 0) { + return list; + } + } catch { + // ignore + } + } + + try { + const global = await apiClient.session.list(undefined); + const list = Array.isArray(global.data) ? global.data : []; + return filterToDirectory(list); + } catch { + return []; + } +}; + +const buildWorktreeMetadataByPath = async (group: AgentGroup, projectDirectory: string): Promise> => { + const map = new Map(); + + group.sessions.forEach((session) => { + if (session.worktreeMetadata) { + map.set(normalize(session.path), session.worktreeMetadata); + } + }); + + const missingPaths = Array.from(new Set(group.sessions.map((session) => normalize(session.path)))) + .filter(Boolean) + .filter((path) => !map.has(path)); + + if (missingPaths.length === 0) { + return map; + } + + try { + const infos = await listWorktrees(projectDirectory); + const infoByPath = new Map(infos.map((info) => [normalize(info.worktree), info])); + missingPaths.forEach((path) => { + const info = infoByPath.get(path); + if (info) { + map.set(path, mapWorktreeToMetadata(projectDirectory, info)); + } + }); + } catch { + // ignore + } + + return map; +}; + +const collectDeleteCandidates = async (params: { + apiClient: ReturnType; + group: AgentGroup; + projectDirectory: string; + worktreePaths: string[]; +}): Promise> => { + const { apiClient, group, projectDirectory, worktreePaths } = params; + const metadataByPath = await buildWorktreeMetadataByPath(group, projectDirectory); + const sessionStore = useSessionStore.getState(); + + const uniqueWorktreePaths = Array.from(new Set(worktreePaths.map((path) => normalize(path)).filter(Boolean))); + const concurrency = 5; + let index = 0; + + const results: Array<{ worktreePath: string; sessionIds: string[]; metadata?: WorktreeMetadata }> = []; + + const worker = async () => { + while (index < uniqueWorktreePaths.length) { + const current = uniqueWorktreePaths[index]; + index += 1; + + const sessionsInGroup = group.sessions.filter((session) => normalize(session.path) === current).map((session) => session.id); + const cached = sessionStore.getSessionsByDirectory(current); + const cachedIds = Array.isArray(cached) ? cached.map((session) => session.id) : []; + + // Prefer the session store cache (already directory-partitioned). If empty, fall back to direct API listing. + let listedIds: string[] = []; + if (cachedIds.length === 0) { + try { + const listed = await listSessionsForDirectory(apiClient, current); + listedIds = listed.map((session) => session.id); + } catch { + listedIds = []; + } + } + + const ids = Array.from(new Set([...cachedIds, ...listedIds, ...sessionsInGroup].filter(Boolean))); + results.push({ + worktreePath: current, + sessionIds: ids, + metadata: metadataByPath.get(current), + }); + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, uniqueWorktreePaths.length) }, worker)); + return results; +}; + /** * Parse a session title to extract group, provider, model, and index. * Title format: groupSlug/provider/model[/index] @@ -158,31 +393,29 @@ export const useAgentGroupsStore = create()( loadGroups: async () => { const currentDirectory = useDirectoryStore.getState().currentDirectory; - if (!currentDirectory) { + const projectDirectory = resolveProjectDirectory(currentDirectory); + + if (!projectDirectory) { set({ groups: [], isLoading: false, error: 'No project directory selected' }); return; } - // Check if we're inside a .openchamber worktree - if so, don't reload - // This prevents groups from disappearing when switching to a worktree session - const normalizedCurrent = normalize(currentDirectory); - if (normalizedCurrent.includes(`/${OPENCHAMBER_DIR}/`)) { - // We're inside a worktree, don't reload groups - set({ isLoading: false }); - return; - } + const normalizedProject = normalize(projectDirectory); + const openChamberRoot = buildOpenChamberRoot(normalizedProject); const previousGroups = get().groups; set({ isLoading: true, error: null }); try { const apiClient = opencodeClient.getApiClient(); + const canonicalProject = await resolveCanonicalDirectory(apiClient, normalizedProject); + const openChamberRootCanonical = buildOpenChamberRoot(canonicalProject); // Get git worktree info first - we need to query each worktree separately let worktreeInfoMap = new Map>[number]>(); let worktreeInfoList: Awaited> = []; try { - worktreeInfoList = await listWorktrees(normalizedCurrent); + worktreeInfoList = await listWorktrees(normalizedProject); worktreeInfoMap = new Map( worktreeInfoList.map((info) => [normalize(info.worktree), info]) ); @@ -190,32 +423,90 @@ export const useAgentGroupsStore = create()( console.debug('Failed to list git worktrees'); } - // Fetch sessions from each worktree directory (sessions are stored per-directory in OpenCode) - // Filter to only .openchamber worktrees (agent group worktrees) - const openchamberWorktrees = worktreeInfoList.filter( - (info) => normalize(info.worktree).includes(`/${OPENCHAMBER_DIR}/`) - ); - - const sessionsMap = new Map(); - - // Fetch sessions from each openchamber worktree - await Promise.all( - openchamberWorktrees.map(async (worktree) => { - try { - const response = await apiClient.session.list({ - directory: normalize(worktree.worktree), - }); - const sessions: Session[] = Array.isArray(response.data) ? response.data : []; - for (const session of sessions) { - sessionsMap.set(session.id, session); - } - } catch (err) { - console.debug('Failed to fetch sessions from worktree:', worktree.worktree, err); + const fetchCandidateSessions = async (): Promise => { + try { + const scoped = await apiClient.session.list({ directory: normalizedProject }); + const list = Array.isArray(scoped.data) ? scoped.data : []; + if (list.some((session) => { + const dir = normalize((session as { directory?: string | null }).directory ?? ''); + return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical); + })) { + return list; } - }) - ); - - const allSessions = Array.from(sessionsMap.values()); + } catch { + // ignore and fall back to global list + } + + const global = await apiClient.session.list(undefined); + return Array.isArray(global.data) ? global.data : []; + }; + + const fetchSessionsByWorktreeDirectories = async (directories: string[]): Promise => { + const sessionsMap = new Map(); + const concurrency = 5; + let index = 0; + + const worker = async () => { + while (index < directories.length) { + const current = directories[index]; + index += 1; + const normalizedDir = normalize(current); + if (!normalizedDir) continue; + + try { + const sessions = await listSessionsForDirectory(apiClient, normalizedDir); + sessions.forEach((session) => sessionsMap.set(session.id, session)); + } catch (err) { + console.debug('Failed to fetch sessions from worktree:', normalizedDir, err); + } + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, directories.length) }, worker)); + return Array.from(sessionsMap.values()); + }; + + const candidateSessions = await fetchCandidateSessions(); + let allSessions = candidateSessions.filter((session) => { + const dir = normalize((session as { directory?: string | null }).directory ?? ''); + if (!dir) { + return false; + } + return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical); + }); + + // Some OpenCode builds do not return sessions across directories in the global list. + // If we didn't discover any group sessions, fall back to querying each `.openchamber` worktree directory directly. + if (allSessions.length === 0) { + const candidates = new Set(); + + // 1) Git worktree list + worktreeInfoList + .map((info) => normalize(info.worktree)) + .filter((worktreePath) => + startsWithDirectory(worktreePath, openChamberRoot) || startsWithDirectory(worktreePath, openChamberRootCanonical) + ) + .forEach((worktreePath) => candidates.add(worktreePath)); + + // 2) Filesystem scan (handles cases where git worktree listing breaks or isn't available) + const roots = Array.from(new Set([openChamberRoot, openChamberRootCanonical].map((p) => normalize(p)).filter(Boolean))); + await Promise.all( + roots.map(async (root) => { + const dirs = await listOpenChamberDirectories(root); + dirs.forEach((dir) => candidates.add(dir)); + }) + ); + + if (candidates.size > 0) { + allSessions = await fetchSessionsByWorktreeDirectories(Array.from(candidates)); + } + } + + const sessionUpdatedAtById = new Map(); + for (const session of allSessions) { + const updatedAt = (session as { time?: { updated?: number | null } }).time?.updated ?? 0; + sessionUpdatedAtById.set(session.id, typeof updatedAt === 'number' ? updatedAt : 0); + } // Parse sessions and group by groupSlug const groupsMap = new Map(); @@ -236,7 +527,7 @@ export const useAgentGroupsStore = create()( branch: worktreeInfo?.branch ?? '', displayLabel: `${parsed.provider}/${parsed.model}`, worktreeMetadata: worktreeInfo - ? mapWorktreeToMetadata(normalizedCurrent, worktreeInfo) + ? mapWorktreeToMetadata(normalizedProject, worktreeInfo) : undefined, }; @@ -253,9 +544,7 @@ export const useAgentGroupsStore = create()( ([name, sessions]) => { // Find the most recent session update time for lastActive const lastActive = sessions.reduce((max, s) => { - // Find the original session to get the time - const originalSession = allSessions.find((os) => os.id === s.id); - const updatedTime = originalSession?.time?.updated ?? 0; + const updatedTime = sessionUpdatedAtById.get(s.id) ?? 0; return Math.max(max, updatedTime); }, 0); @@ -305,6 +594,196 @@ export const useAgentGroupsStore = create()( set({ selectedSessionId: sessionId }); }, + deleteGroup: async (groupName) => { + const group = get().groups.find((g) => g.name === groupName); + if (!group) { + return false; + } + + const currentDirectory = useDirectoryStore.getState().currentDirectory; + const projectDirectory = resolveProjectDirectory(currentDirectory); + if (!projectDirectory) { + set({ error: 'No project directory selected' }); + return false; + } + + set({ isLoading: true, error: null }); + try { + const apiClient = opencodeClient.getApiClient(); + const candidates = await collectDeleteCandidates({ + apiClient, + group, + projectDirectory: normalize(projectDirectory), + worktreePaths: group.sessions.map((s) => s.path), + }); + + const sessionStore = useSessionStore.getState(); + const ids = new Set(); + candidates.forEach(({ worktreePath, sessionIds, metadata }) => { + sessionIds.forEach((id) => { + ids.add(id); + if (metadata) { + sessionStore.setWorktreeMetadata(id, metadata); + sessionStore.setSessionDirectory(id, worktreePath); + } + }); + }); + + const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true }); + if (failedIds.length > 0) { + set({ error: 'Failed to delete some sessions' }); + } + + if (get().selectedGroupName === groupName) { + set({ selectedGroupName: null, selectedSessionId: null }); + } + + await get().loadGroups(); + return failedIds.length === 0; + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to delete group' }); + return false; + } finally { + set({ isLoading: false }); + } + }, + + deleteGroupWorktree: async (groupName, worktreePath) => { + const group = get().groups.find((g) => g.name === groupName); + if (!group) { + return false; + } + const normalizedWorktreePath = normalize(worktreePath); + if (!normalizedWorktreePath) { + return false; + } + + const currentDirectory = useDirectoryStore.getState().currentDirectory; + const projectDirectory = resolveProjectDirectory(currentDirectory); + if (!projectDirectory) { + set({ error: 'No project directory selected' }); + return false; + } + + set({ isLoading: true, error: null }); + try { + const apiClient = opencodeClient.getApiClient(); + const candidates = await collectDeleteCandidates({ + apiClient, + group, + projectDirectory: normalize(projectDirectory), + worktreePaths: [normalizedWorktreePath], + }); + + const sessionStore = useSessionStore.getState(); + const ids = new Set(); + candidates.forEach(({ worktreePath: resolvedPath, sessionIds, metadata }) => { + sessionIds.forEach((id) => { + ids.add(id); + if (metadata) { + sessionStore.setWorktreeMetadata(id, metadata); + sessionStore.setSessionDirectory(id, resolvedPath); + } + }); + }); + + const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true }); + if (failedIds.length > 0) { + set({ error: 'Failed to delete some sessions' }); + } + + await get().loadGroups(); + + const updated = get().groups.find((g) => g.name === groupName); + if (!updated) { + if (get().selectedGroupName === groupName) { + set({ selectedGroupName: null, selectedSessionId: null }); + } + return failedIds.length === 0; + } + + if (get().selectedGroupName === groupName) { + const currentSelected = get().selectedSessionId; + const remainingIds = new Set(updated.sessions.map((s) => s.id)); + if (!currentSelected || !remainingIds.has(currentSelected)) { + set({ selectedSessionId: updated.sessions[0]?.id ?? null }); + } + } + + return failedIds.length === 0; + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to delete worktree' }); + return false; + } finally { + set({ isLoading: false }); + } + }, + + keepOnlyGroupWorktree: async (groupName, keepWorktreePath) => { + const group = get().groups.find((g) => g.name === groupName); + if (!group) { + return false; + } + const keepPath = normalize(keepWorktreePath); + if (!keepPath) { + return false; + } + + const worktreePaths = Array.from(new Set(group.sessions.map((s) => normalize(s.path)).filter(Boolean))); + const toDelete = worktreePaths.filter((path) => path !== keepPath); + if (toDelete.length === 0) { + return true; + } + + const currentDirectory = useDirectoryStore.getState().currentDirectory; + const projectDirectory = resolveProjectDirectory(currentDirectory); + if (!projectDirectory) { + set({ error: 'No project directory selected' }); + return false; + } + + set({ isLoading: true, error: null }); + try { + const apiClient = opencodeClient.getApiClient(); + const candidates = await collectDeleteCandidates({ + apiClient, + group, + projectDirectory: normalize(projectDirectory), + worktreePaths: toDelete, + }); + + const sessionStore = useSessionStore.getState(); + const ids = new Set(); + candidates.forEach(({ worktreePath, sessionIds, metadata }) => { + sessionIds.forEach((id) => { + ids.add(id); + if (metadata) { + sessionStore.setWorktreeMetadata(id, metadata); + sessionStore.setSessionDirectory(id, worktreePath); + } + }); + }); + + const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true }); + if (failedIds.length > 0) { + set({ error: 'Failed to delete some sessions' }); + } + + await get().loadGroups(); + if (get().selectedGroupName === groupName) { + const updated = get().groups.find((g) => g.name === groupName); + const keepSession = updated?.sessions.find((s) => normalize(s.path) === keepPath) ?? updated?.sessions[0] ?? null; + set({ selectedSessionId: keepSession?.id ?? null }); + } + return failedIds.length === 0; + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to remove other worktrees' }); + return false; + } finally { + set({ isLoading: false }); + } + }, + getSelectedGroup: () => { const { groups, selectedGroupName } = get(); if (!selectedGroupName) return null; @@ -321,47 +800,6 @@ export const useAgentGroupsStore = create()( clearError: () => { set({ error: null }); }, - - deleteGroup: async (groupName: string) => { - const { groups, selectedGroupName } = get(); - const group = groups.find((g) => g.name === groupName); - - if (!group) { - return { success: false, deletedCount: 0, failedCount: 0 }; - } - - // Get all session IDs from the group - const sessionIds = group.sessions.map((s) => s.id); - - if (sessionIds.length === 0) { - return { success: true, deletedCount: 0, failedCount: 0 }; - } - - // Delete sessions using sessionStore.deleteSessions - // archiveWorktree: true - removes the git worktree - // deleteRemoteBranch: false - does not delete remote branch - const { deletedIds, failedIds } = await useSessionStore.getState().deleteSessions( - sessionIds, - { - archiveWorktree: true, - deleteRemoteBranch: false, - } - ); - - // If the deleted group was selected, clear selection - if (selectedGroupName === groupName) { - set({ selectedGroupName: null, selectedSessionId: null }); - } - - // Reload groups to reflect changes - await get().loadGroups(); - - return { - success: failedIds.length === 0, - deletedCount: deletedIds.length, - failedCount: failedIds.length, - }; - }, }), { name: 'agent-groups-store' } ) diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index da44c193..aa37dc0d 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -1,9 +1,9 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import type { Agent } from "@opencode-ai/sdk/v2"; +import type { Agent, PermissionConfig } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; -import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; +import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync"; import { startConfigUpdate, finishConfigUpdate, @@ -11,13 +11,20 @@ import { } from "@/lib/configUpdate"; import { getSafeStorage } from "./utils/safeStorage"; import { useConfigStore } from "@/stores/useConfigStore"; +import { useCommandsStore } from "@/stores/useCommandsStore"; +import { useProjectsStore } from "@/stores/useProjectsStore"; +import { useSkillsCatalogStore } from "@/stores/useSkillsCatalogStore"; +import { useSkillsStore } from "@/stores/useSkillsStore"; // Note: useDirectoryStore cannot be imported at top level to avoid circular dependency // useDirectoryStore -> useAgentsStore (for refreshAfterOpenCodeRestart) // useAgentsStore -> useDirectoryStore (for currentDirectory) -// Instead we access it from the window object where it's exposed const getCurrentDirectory = (): string | null => { - // Try to get from window if store is already loaded + const opencodeDirectory = opencodeClient.getDirectory(); + if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) { + return opencodeDirectory; + } + try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const store = (window as any).__zustand_directory_store__; @@ -27,6 +34,29 @@ const getCurrentDirectory = (): string | null => { } catch { // ignore } + + return null; +}; + +const getConfigDirectory = (): string | null => { + try { + const projectsStore = useProjectsStore.getState(); + const activeProject = projectsStore.getActiveProject?.(); + + // 1. Primary: Active project path from store + if (activeProject?.path?.trim()) { + return activeProject.path.trim(); + } + + // 2. Fallback: current OpenCode directory (session / runtime) + const clientDir = opencodeClient.getDirectory(); + if (clientDir?.trim()) { + return clientDir.trim(); + } + } catch (err) { + console.warn('[AgentsStore] Error resolving config directory:', err); + } + return null; }; @@ -40,15 +70,7 @@ export interface AgentConfig { top_p?: number; prompt?: string; mode?: "primary" | "subagent" | "all"; - tools?: Record; - permission?: { - edit?: "allow" | "ask" | "deny"; - bash?: "allow" | "ask" | "deny" | Record; - skill?: "allow" | "ask" | "deny" | Record; - webfetch?: "allow" | "ask" | "deny"; - doom_loop?: "allow" | "ask" | "deny"; - external_directory?: "allow" | "ask" | "deny"; - }; + permission?: PermissionConfig | null; disable?: boolean; scope?: AgentScope; @@ -96,15 +118,7 @@ export interface AgentDraft { top_p?: number; prompt?: string; mode?: "primary" | "subagent" | "all"; - tools?: Record; - permission?: { - edit?: "allow" | "ask" | "deny"; - bash?: "allow" | "ask" | "deny" | Record; - skill?: "allow" | "ask" | "deny" | Record; - webfetch?: "allow" | "ask" | "deny"; - doom_loop?: "allow" | "ask" | "deny"; - external_directory?: "allow" | "ask" | "deny"; - }; + permission?: PermissionConfig; disable?: boolean; } @@ -153,44 +167,67 @@ export const useAgentsStore = create()( loadAgents: async () => { set({ isLoading: true }); const previousAgents = get().agents; - let lastError: unknown = null; for (let attempt = 0; attempt < 3; attempt++) { try { - const agents = await opencodeClient.listAgents(); - - // Fetch scope info for each agent - const currentDirectory = getCurrentDirectory(); - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - + const configDirectory = getConfigDirectory(); + const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; + + // Ensure we list agents using the correct project context + const agents = await opencodeClient.withDirectory(configDirectory, () => opencodeClient.listAgents()); + const agentsWithScope = await Promise.all( agents.map(async (agent) => { try { - const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`); + // Force no-cache to ensure we get the latest scope info + const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`, { + headers: { + 'Cache-Control': 'no-cache', + ...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}), + } + }); + if (response.ok) { const data = await response.json(); - // Handle web/desktop response formats; fall back to JSON scope if md scope missing - const scope = data.scope ?? data.sources?.md?.scope ?? data.sources?.json?.scope; - return { ...agent, scope: scope as AgentScope | undefined }; + + // Prioritize explicit scope from server response + let scope = data.scope; + + // Fallback to deducing from sources if top-level scope is missing + if (!scope && data.sources) { + const sources = data.sources; + scope = (sources.md?.exists ? sources.md.scope : undefined) + ?? (sources.json?.exists ? sources.json.scope : undefined) + ?? sources.md?.scope + ?? sources.json?.scope; + } + + if (scope === 'project' || scope === 'user') { + return { ...agent, scope: scope as AgentScope }; + } + + // Explicitly set null scope if not found, to clear stale state + return { ...agent, scope: undefined }; } - } catch { - // Ignore scope fetch errors and fall back to agent defaults + } catch (err) { + console.warn(`[AgentsStore] Failed to fetch config for agent ${agent.name}:`, err); } return agent; }) ); - - set({ agents: agentsWithScope, isLoading: false }); + + if (JSON.stringify(previousAgents) !== JSON.stringify(agentsWithScope)) { + set({ agents: agentsWithScope, isLoading: false }); + } else { + set({ isLoading: false }); + } return true; - } catch (error) { - lastError = error; - const waitMs = 200 * (attempt + 1); - await new Promise((resolve) => setTimeout(resolve, waitMs)); + } catch { + // ignore error } } - - console.error("Failed to load agents:", lastError); - set({ agents: previousAgents, isLoading: false }); + + set({ isLoading: false }); return false; }, @@ -198,8 +235,10 @@ export const useAgentsStore = create()( startConfigUpdate("Creating agent configuration…"); let requiresReload = false; try { + console.log('[AgentsStore] Creating agent:', config.name); + const agentConfig: Record = { - mode: config.mode || "subagent", + mode: config.mode || 'subagent', }; if (config.description) agentConfig.description = config.description; @@ -207,18 +246,21 @@ export const useAgentsStore = create()( if (config.temperature !== undefined) agentConfig.temperature = config.temperature; if (config.top_p !== undefined) agentConfig.top_p = config.top_p; if (config.prompt) agentConfig.prompt = config.prompt; - if (config.tools && Object.keys(config.tools).length > 0) agentConfig.tools = config.tools; if (config.permission) agentConfig.permission = config.permission; if (config.disable !== undefined) agentConfig.disable = config.disable; if (config.scope) agentConfig.scope = config.scope; - // Get current directory for project-level agent support - const currentDirectory = getCurrentDirectory(); - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + console.log('[AgentsStore] Agent config to save:', agentConfig); + + const configDirectory = getConfigDirectory(); + const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}), + }, body: JSON.stringify(agentConfig) }); @@ -231,9 +273,11 @@ export const useAgentsStore = create()( const needsReload = payload?.requiresReload ?? true; if (needsReload) { requiresReload = true; - await performFullConfigRefresh({ + await refreshAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, + scopes: ["agents"], + mode: "active", }); return true; } @@ -243,7 +287,8 @@ export const useAgentsStore = create()( emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } return loaded; - } catch { + } catch (error) { + console.error('Failed to create agent:', error); return false; } finally { if (!requiresReload) { @@ -264,17 +309,19 @@ export const useAgentsStore = create()( if (config.temperature !== undefined) agentConfig.temperature = config.temperature; if (config.top_p !== undefined) agentConfig.top_p = config.top_p; if (config.prompt !== undefined) agentConfig.prompt = config.prompt; - if (config.tools !== undefined) agentConfig.tools = config.tools; if (config.permission !== undefined) agentConfig.permission = config.permission; if (config.disable !== undefined) agentConfig.disable = config.disable; - // Get current directory for project-level agent support - const currentDirectory = getCurrentDirectory(); - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + // Use active project root for project-level agent support. + const configDirectory = getConfigDirectory(); + const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}), + }, body: JSON.stringify(agentConfig) }); @@ -287,9 +334,11 @@ export const useAgentsStore = create()( const needsReload = payload?.requiresReload ?? true; if (needsReload) { requiresReload = true; - await performFullConfigRefresh({ + await refreshAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, + scopes: ["agents"], + mode: "active", }); return true; } @@ -299,8 +348,9 @@ export const useAgentsStore = create()( emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } return loaded; - } catch { - return false; + } catch (error) { + console.error('Failed to update agent:', error); + throw error; } finally { if (!requiresReload) { finishConfigUpdate(); @@ -312,12 +362,13 @@ export const useAgentsStore = create()( startConfigUpdate("Deleting agent configuration…"); let requiresReload = false; try { - // Get current directory for project-level agent support - const currentDirectory = getCurrentDirectory(); - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + // Use active project root for project-level agent support. + const configDirectory = getConfigDirectory(); + const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { - method: 'DELETE' + method: 'DELETE', + headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined, }); const payload = await response.json().catch(() => null); @@ -329,9 +380,11 @@ export const useAgentsStore = create()( const needsReload = payload?.requiresReload ?? true; if (needsReload) { requiresReload = true; - await performFullConfigRefresh({ + await refreshAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, + scopes: ["agents"], + mode: "active", }); return true; } @@ -344,7 +397,6 @@ export const useAgentsStore = create()( if (get().selectedAgentName === name) { set({ selectedAgentName: null }); } - return loaded; } catch { return false; @@ -355,6 +407,7 @@ export const useAgentsStore = create()( } }, + getAgentByName: (name: string) => { const { agents } = get(); return agents.find((a) => a.name === name); @@ -427,45 +480,112 @@ async function waitForOpenCodeConnection(delayMs?: number) { throw lastError || new Error("OpenCode did not become ready in time"); } -async function performFullConfigRefresh(options: { message?: string; delayMs?: number } = {}) { +type ConfigRefreshMode = "active" | "projects"; + +const normalizeRefreshScopes = (scopes?: ConfigChangeScope[]): ConfigChangeScope[] => { + if (!scopes || scopes.length === 0) { + return ["all"]; + } + + const unique = Array.from(new Set(scopes)); + if (unique.includes("all")) { + return ["all"]; + } + + return unique; +}; + +async function performConfigRefresh(options: { + message?: string; + delayMs?: number; + scopes?: ConfigChangeScope[]; + mode?: ConfigRefreshMode; +} = {}) { const { message, delayMs } = options; + const scopes = normalizeRefreshScopes(options.scopes); + const mode: ConfigRefreshMode = options.mode ?? (scopes.includes("all") ? "projects" : "active"); try { - updateConfigUpdateMessage(message || "Reloading OpenCode configuration…"); - if (typeof window !== "undefined" && window.localStorage) { - window.localStorage.removeItem("agents-store"); - window.localStorage.removeItem("config-store"); - } + updateConfigUpdateMessage(message || "Refreshing configuration…"); } catch { - // Ignore local storage cleanup errors + // ignore } try { await waitForOpenCodeConnection(delayMs); - updateConfigUpdateMessage("Refreshing providers and agents…"); const configStore = useConfigStore.getState(); - const agentsStore = useAgentsStore.getState(); + const agentConfigStore = useAgentsStore.getState(); + const commandsStore = useCommandsStore.getState(); + const skillsStore = useSkillsStore.getState(); + const skillsCatalogStore = useSkillsCatalogStore.getState(); - await Promise.all([ - configStore.loadProviders().then(() => undefined), - agentsStore.loadAgents().then(() => undefined), - ]); + const refreshProviders = scopes.includes("all") || scopes.includes("providers"); + const refreshSdkAgents = scopes.includes("all") || scopes.includes("agents"); + const refreshAgentConfigs = scopes.includes("all") || scopes.includes("agents"); + const refreshCommands = scopes.includes("all") || scopes.includes("commands"); + const refreshSkills = scopes.includes("all") || scopes.includes("skills"); - emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); + const currentDirectory = getCurrentDirectory(); + const projects = mode === "projects" ? useProjectsStore.getState().projects : []; + const directoriesToRefresh = Array.from( + new Set([ + ...(currentDirectory ? [currentDirectory] : []), + ...projects.map((project) => project.path).filter(Boolean), + ]), + ); + + if (scopes.includes("all") && mode === "projects") { + useConfigStore.setState({ directoryScoped: {} }); + } + + const sdkRefreshTasks: Promise[] = []; + for (const directory of directoriesToRefresh) { + if (refreshProviders) { + sdkRefreshTasks.push(configStore.loadProviders({ directory }).then(() => undefined)); + } + if (refreshSdkAgents) { + sdkRefreshTasks.push(configStore.loadAgents({ directory }).then(() => undefined)); + } + } + + const uiRefreshTasks: Promise[] = []; + if (refreshAgentConfigs) { + uiRefreshTasks.push(agentConfigStore.loadAgents().then(() => undefined)); + } + if (refreshCommands) { + uiRefreshTasks.push(commandsStore.loadCommands().then(() => undefined)); + } + if (refreshSkills) { + uiRefreshTasks.push(skillsStore.loadSkills().then(() => undefined)); + uiRefreshTasks.push(skillsCatalogStore.loadCatalog().then(() => undefined)); + } + + updateConfigUpdateMessage("Refreshing configuration…"); + await Promise.all([...sdkRefreshTasks, ...uiRefreshTasks]); } catch { - updateConfigUpdateMessage("OpenCode reload failed. Please retry refreshing configuration manually."); + updateConfigUpdateMessage("OpenCode refresh failed. Please retry."); await sleep(1500); } finally { finishConfigUpdate(); } } -export async function refreshAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) { - await performFullConfigRefresh(options); +export async function refreshAfterOpenCodeRestart(options?: { + message?: string; + delayMs?: number; + scopes?: ConfigChangeScope[]; + mode?: ConfigRefreshMode; +}) { + await performConfigRefresh(options); } -export async function reloadOpenCodeConfiguration(options?: { message?: string; delayMs?: number }) { +export async function reloadOpenCodeConfiguration(options?: { + message?: string; + delayMs?: number; + scopes?: ConfigChangeScope[]; + mode?: ConfigRefreshMode; +}) { startConfigUpdate(options?.message || "Reloading OpenCode configuration…"); try { @@ -482,14 +602,20 @@ export async function reloadOpenCodeConfiguration(options?: { message?: string; throw new Error(message); } + const refreshOptions = { + ...options, + scopes: options?.scopes ?? ["all"], + mode: options?.mode ?? "projects", + }; + if (payload?.requiresReload) { - await performFullConfigRefresh({ + await refreshAfterOpenCodeRestart({ + ...refreshOptions, message: payload.message, delayMs: payload.reloadDelayMs, }); } else { - - await performFullConfigRefresh(options); + await refreshAfterOpenCodeRestart(refreshOptions); } } catch (error) { console.error('[reloadOpenCodeConfiguration] Failed:', error); diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index e37d68b3..ee09f7f3 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -9,8 +9,8 @@ import { } from "@/lib/configUpdate"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { getSafeStorage } from "./utils/safeStorage"; -import { useConfigStore } from "@/stores/useConfigStore"; -import { useDirectoryStore } from "@/stores/useDirectoryStore"; +import { useProjectsStore } from "@/stores/useProjectsStore"; + export type CommandScope = 'user' | 'project'; @@ -37,6 +37,29 @@ export const isCommandBuiltIn = (command: Command): boolean => { const CONFIG_EVENT_SOURCE = "useCommandsStore"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const getRequestDirectory = (): string | null => { + try { + const projectsStore = useProjectsStore.getState(); + const activeProject = projectsStore.getActiveProject?.(); + + // 1. Primary: Active project path from store + if (activeProject?.path?.trim()) { + return activeProject.path.trim(); + } + + // 2. Fallback: current OpenCode directory (session / runtime) + const clientDir = opencodeClient.getDirectory(); + if (clientDir?.trim()) { + return clientDir.trim(); + } + } catch (err) { + console.warn('[CommandsStore] Error resolving config directory:', err); + } + + return null; +}; + const MAX_HEALTH_WAIT_MS = 20000; const FAST_HEALTH_POLL_INTERVAL_MS = 300; const FAST_HEALTH_POLL_ATTEMPTS = 4; @@ -101,30 +124,60 @@ export const useCommandsStore = create()( for (let attempt = 0; attempt < 3; attempt++) { try { - const commands = await opencodeClient.listCommandsWithDetails(); + const directory = getRequestDirectory(); + const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; - // Fetch scope info for each command - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + // Ensure the list is scoped to the same directory we use for config source detection. + const commands = await opencodeClient.withDirectory( + directory, + () => opencodeClient.listCommandsWithDetails() + ); const commandsWithScope = await Promise.all( commands.map(async (cmd) => { try { - const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`); + // Force no-cache + const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, { + headers: { + 'Cache-Control': 'no-cache', + ...(directory ? { 'x-opencode-directory': directory } : {}), + } + }); + if (response.ok) { const data = await response.json(); - // Handle both web (data.scope) and desktop (data.sources.md.scope) response formats - const scope = data.scope ?? data.sources?.md?.scope; - return { ...cmd, scope: scope as CommandScope | undefined }; + + // Prioritize explicit scope + let scope = data.scope; + + // Fallback to deducing from sources + if (!scope && data.sources) { + const sources = data.sources; + scope = (sources.md?.exists ? sources.md.scope : undefined) + ?? (sources.json?.exists ? sources.json.scope : undefined) + ?? sources.md?.scope + ?? sources.json?.scope; + } + + if (scope === 'project' || scope === 'user') { + return { ...cmd, scope: scope as CommandScope }; + } + + // Explicitly set null scope if not found + return { ...cmd, scope: undefined }; } - } catch { - // Ignore errors fetching scope + } catch (err) { + console.warn(`[CommandsStore] Failed to fetch config for command ${cmd.name}:`, err); } return cmd; }) ); - set({ commands: commandsWithScope, isLoading: false }); + if (JSON.stringify(previousCommands) !== JSON.stringify(commandsWithScope)) { + set({ commands: commandsWithScope, isLoading: false }); + } else { + set({ isLoading: false }); + } return true; } catch (error) { lastError = error; @@ -156,13 +209,15 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command config to save:', commandConfig); - // Get current directory for project-level command support - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + const directory = getRequestDirectory(); + const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(directory ? { 'x-opencode-directory': directory } : {}), + }, body: JSON.stringify(commandConfig) }); @@ -216,13 +271,15 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command config to update:', commandConfig); - // Get current directory for project-level command support - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + const directory = getRequestDirectory(); + const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(directory ? { 'x-opencode-directory': directory } : {}), + }, body: JSON.stringify(commandConfig) }); @@ -263,12 +320,13 @@ export const useCommandsStore = create()( startConfigUpdate("Deleting command configuration…"); let requiresReload = false; try { - // Get current directory for project-level command support - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + // Use active project root for project-level command support + const directory = getRequestDirectory(); + const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { - method: 'DELETE' + method: 'DELETE', + headers: directory ? { 'x-opencode-directory': directory } : undefined, }); const payload = await response.json().catch(() => null); @@ -380,31 +438,23 @@ async function performFullConfigRefresh(options: { message?: string; delayMs?: n const { message, delayMs } = options; try { - updateConfigUpdateMessage(message || "Reloading OpenCode configuration…"); - if (typeof window !== "undefined" && window.localStorage) { - window.localStorage.removeItem("commands-store"); - window.localStorage.removeItem("config-store"); - } - } catch (error) { - console.warn("[CommandsStore] Failed to prepare config refresh:", error); + updateConfigUpdateMessage(message || "Refreshing commands…"); + } catch { + // ignore } try { await waitForOpenCodeConnection(delayMs); - updateConfigUpdateMessage("Refreshing providers and commands…"); + updateConfigUpdateMessage("Refreshing commands…"); - const configStore = useConfigStore.getState(); const commandsStore = useCommandsStore.getState(); - await Promise.all([ - configStore.loadProviders().then(() => undefined), - commandsStore.loadCommands().then(() => undefined), - ]); + await commandsStore.loadCommands(); emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); } catch (error) { console.error("[CommandsStore] Failed to refresh configuration after OpenCode restart:", error); - updateConfigUpdateMessage("OpenCode reload failed. Please retry refreshing configuration manually."); + updateConfigUpdateMessage("OpenCode refresh failed. Please retry refreshing configuration manually."); await sleep(1500); } finally { finishConfigUpdate(); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 3246e7e0..e648df99 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -11,6 +11,8 @@ import { filterVisibleAgents } from "./useAgentsStore"; import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; import { updateDesktopSettings } from "@/lib/persistence"; +import { useDirectoryStore } from "@/stores/useDirectoryStore"; +import { streamDebugEnabled } from "@/stores/utils/streamDebug"; const MODELS_DEV_API_URL = "https://models.dev/api.json"; const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata"; @@ -271,10 +273,70 @@ const fetchModelsDevMetadata = async (): Promise> => return new Map(); }; +let modelsMetadataInFlight: Promise> | null = null; + +const ensureModelsMetadataFetch = ( + getModelsMetadata: () => Map, + setModelsMetadata: (metadata: Map) => void, +) => { + const existing = getModelsMetadata(); + if (existing.size > 0) { + return; + } + + if (modelsMetadataInFlight) { + return; + } + + modelsMetadataInFlight = fetchModelsDevMetadata() + .then((metadata) => { + if (metadata.size > 0) { + setModelsMetadata(metadata); + } + return metadata; + }) + .catch(() => new Map()) + .finally(() => { + modelsMetadataInFlight = null; + }); +}; + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const DIRECTORY_KEY_GLOBAL = "__global__"; + +const toDirectoryKey = (directory: string | null | undefined): string => { + const trimmed = typeof directory === 'string' ? directory.trim() : ''; + return trimmed.length > 0 ? trimmed : DIRECTORY_KEY_GLOBAL; +}; + +const fromDirectoryKey = (key: string): string | null => (key === DIRECTORY_KEY_GLOBAL ? null : key); + +const resolveInitialDirectoryKey = (): string => { + if (typeof window === 'undefined') { + return DIRECTORY_KEY_GLOBAL; + } + + const directory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory; + return toDirectoryKey(directory); +}; + +interface DirectoryScopedConfig { + providers: ProviderWithModelList[]; + agents: Agent[]; + currentProviderId: string; + currentModelId: string; + currentAgentName: string | undefined; + selectedProviderId: string; + agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } }; + defaultProviders: { [key: string]: string }; +} + interface ConfigStore { + activeDirectoryKey: string; + directoryScoped: Record; + providers: ProviderWithModelList[]; agents: Agent[]; currentProviderId: string; @@ -290,8 +352,10 @@ interface ConfigStore { settingsDefaultModel: string | undefined; // format: "provider/model" settingsDefaultAgent: string | undefined; - loadProviders: () => Promise; - loadAgents: () => Promise; + activateDirectory: (directory: string | null | undefined) => Promise; + + loadProviders: (options?: { directory?: string | null }) => Promise; + loadAgents: (options?: { directory?: string | null }) => Promise; setProvider: (providerId: string) => void; setModel: (modelId: string) => void; setAgent: (agentName: string | undefined) => void; @@ -322,6 +386,9 @@ export const useConfigStore = create()( persist( (set, get) => ({ + activeDirectoryKey: resolveInitialDirectoryKey(), + directoryScoped: {}, + providers: [], agents: [], currentProviderId: "", @@ -336,15 +403,63 @@ export const useConfigStore = create()( settingsDefaultModel: undefined, settingsDefaultAgent: undefined, - loadProviders: async () => { - const previousProviders = get().providers; - const previousDefaults = get().defaultProviders; + activateDirectory: async (directory) => { + const directoryKey = toDirectoryKey(directory); + + set((state) => { + const snapshot = state.directoryScoped[directoryKey]; + if (snapshot) { + return { + activeDirectoryKey: directoryKey, + providers: snapshot.providers, + agents: snapshot.agents, + currentProviderId: snapshot.currentProviderId, + currentModelId: snapshot.currentModelId, + currentAgentName: snapshot.currentAgentName, + selectedProviderId: snapshot.selectedProviderId, + agentModelSelections: snapshot.agentModelSelections, + defaultProviders: snapshot.defaultProviders, + }; + } + + return { + activeDirectoryKey: directoryKey, + providers: [], + agents: [], + currentProviderId: "", + currentModelId: "", + currentAgentName: undefined, + selectedProviderId: "", + agentModelSelections: {}, + defaultProviders: {}, + }; + }); + + if (!get().isConnected) { + return; + } + + await get().loadProviders({ directory: fromDirectoryKey(directoryKey) }); + await get().loadAgents({ directory: fromDirectoryKey(directoryKey) }); + }, + + loadProviders: async (options) => { + const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey)); + const existingSnapshot = get().directoryScoped[directoryKey]; + const previousProviders = existingSnapshot?.providers ?? (get().activeDirectoryKey === directoryKey ? get().providers : []); + const previousDefaults = existingSnapshot?.defaultProviders ?? (get().activeDirectoryKey === directoryKey ? get().defaultProviders : {}); let lastError: unknown = null; for (let attempt = 0; attempt < 3; attempt++) { try { - const metadataPromise = fetchModelsDevMetadata(); - const apiResult = await opencodeClient.getProviders(); + ensureModelsMetadataFetch( + () => get().modelsMetadata, + (metadata) => set({ modelsMetadata: metadata }), + ); + const apiResult = await opencodeClient.withDirectory( + fromDirectoryKey(directoryKey), + () => opencodeClient.getProviders() + ); const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : []; const defaults = apiResult?.default || {}; @@ -357,16 +472,39 @@ export const useConfigStore = create()( }; }); - // Only store providers and defaults - model/agent selection handled in loadAgents - set({ - providers: processedProviders, - defaultProviders: defaults, + set((state) => { + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: [], + agents: [], + currentProviderId: "", + currentModelId: "", + currentAgentName: undefined, + selectedProviderId: "", + agentModelSelections: {}, + defaultProviders: {}, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + providers: processedProviders, + defaultProviders: defaults, + }; + + const nextState: Partial = { + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + + if (state.activeDirectoryKey === directoryKey) { + nextState.providers = processedProviders; + nextState.defaultProviders = defaults; + } + + return nextState; }); - const metadata = await metadataPromise; - if (metadata.size > 0) { - set({ modelsMetadata: metadata }); - } return; } catch (error) { lastError = error; @@ -376,10 +514,38 @@ export const useConfigStore = create()( } console.error("Failed to load providers:", lastError); - // Preserve previous state on failure instead of clearing it - set({ - providers: previousProviders, - defaultProviders: previousDefaults, + + set((state) => { + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: [], + agents: [], + currentProviderId: "", + currentModelId: "", + currentAgentName: undefined, + selectedProviderId: "", + agentModelSelections: {}, + defaultProviders: {}, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + providers: previousProviders, + defaultProviders: previousDefaults, + }; + + const nextState: Partial = { + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + + if (state.activeDirectoryKey === directoryKey) { + nextState.providers = previousProviders; + nextState.defaultProviders = previousDefaults; + } + + return nextState; }); }, @@ -387,34 +553,135 @@ export const useConfigStore = create()( const { providers } = get(); const provider = providers.find((p) => p.id === providerId); - if (provider) { + if (!provider) { + return; + } - const firstModel = provider.models[0]; - const newModelId = firstModel?.id || ""; + const firstModel = provider.models[0]; + const newModelId = firstModel?.id || ""; - set({ + set((state) => { + const directoryKey = state.activeDirectoryKey; + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: state.providers, + agents: state.agents, + currentProviderId: state.currentProviderId, + currentModelId: state.currentModelId, + currentAgentName: state.currentAgentName, + selectedProviderId: state.selectedProviderId, + agentModelSelections: state.agentModelSelections, + defaultProviders: state.defaultProviders, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, currentProviderId: providerId, currentModelId: newModelId, selectedProviderId: providerId, - }); - } + }; + + return { + currentProviderId: providerId, + currentModelId: newModelId, + selectedProviderId: providerId, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + }); }, setModel: (modelId: string) => { - set({ currentModelId: modelId }); + set((state) => { + const directoryKey = state.activeDirectoryKey; + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: state.providers, + agents: state.agents, + currentProviderId: state.currentProviderId, + currentModelId: state.currentModelId, + currentAgentName: state.currentAgentName, + selectedProviderId: state.selectedProviderId, + agentModelSelections: state.agentModelSelections, + defaultProviders: state.defaultProviders, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + currentModelId: modelId, + }; + + return { + currentModelId: modelId, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + }); }, setSelectedProvider: (providerId: string) => { - set({ selectedProviderId: providerId }); + set((state) => { + const directoryKey = state.activeDirectoryKey; + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: state.providers, + agents: state.agents, + currentProviderId: state.currentProviderId, + currentModelId: state.currentModelId, + currentAgentName: state.currentAgentName, + selectedProviderId: state.selectedProviderId, + agentModelSelections: state.agentModelSelections, + defaultProviders: state.defaultProviders, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + selectedProviderId: providerId, + }; + + return { + selectedProviderId: providerId, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + }); }, saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => { - set((state) => ({ - agentModelSelections: { + set((state) => { + const directoryKey = state.activeDirectoryKey; + const nextSelections = { ...state.agentModelSelections, [agentName]: { providerId, modelId }, - }, - })); + }; + + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: state.providers, + agents: state.agents, + currentProviderId: state.currentProviderId, + currentModelId: state.currentModelId, + currentAgentName: state.currentAgentName, + selectedProviderId: state.selectedProviderId, + agentModelSelections: state.agentModelSelections, + defaultProviders: state.defaultProviders, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + agentModelSelections: nextSelections, + }; + + return { + agentModelSelections: nextSelections, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + }); }, getAgentModelSelection: (agentName: string) => { @@ -422,40 +689,97 @@ export const useConfigStore = create()( return agentModelSelections[agentName] || null; }, - loadAgents: async () => { - const previousAgents = get().agents; + loadAgents: async (options) => { + const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey)); + const existingSnapshot = get().directoryScoped[directoryKey]; + const previousAgents = existingSnapshot?.agents ?? (get().activeDirectoryKey === directoryKey ? get().agents : []); let lastError: unknown = null; for (let attempt = 0; attempt < 3; attempt++) { try { // Fetch agents and OpenChamber settings in parallel const [agents, openChamberDefaults] = await Promise.all([ - opencodeClient.listAgents(), + opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()), fetchOpenChamberDefaults(), ]); + const safeAgents = Array.isArray(agents) ? agents : []; - set({ - agents: safeAgents, - // Store settings defaults so setAgent can respect them - settingsDefaultModel: openChamberDefaults.defaultModel, - settingsDefaultAgent: openChamberDefaults.defaultAgent, + + const providers = get().activeDirectoryKey === directoryKey + ? get().providers + : (get().directoryScoped[directoryKey]?.providers ?? []); + + set((state) => { + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers, + agents: previousAgents, + currentProviderId: "", + currentModelId: "", + currentAgentName: undefined, + selectedProviderId: "", + agentModelSelections: {}, + defaultProviders: {}, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + providers, + agents: safeAgents, + }; + + const nextState: Partial = { + settingsDefaultModel: openChamberDefaults.defaultModel, + settingsDefaultAgent: openChamberDefaults.defaultAgent, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + + if (state.activeDirectoryKey === directoryKey) { + nextState.agents = safeAgents; + } + + return nextState; }); - const { providers } = get(); - if (safeAgents.length === 0) { - set({ currentAgentName: undefined }); + set((state) => { + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers, + agents: [], + currentProviderId: "", + currentModelId: "", + currentAgentName: undefined, + selectedProviderId: "", + agentModelSelections: {}, + defaultProviders: {}, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + providers, + agents: [], + currentAgentName: undefined, + }; + + const nextState: Partial = { + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + + if (state.activeDirectoryKey === directoryKey) { + nextState.currentAgentName = undefined; + } + + return nextState; + }); + return true; } - // --- Agent Selection --- - // Priority: settings.defaultAgent → build → first primary → first agent - const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode)); - const buildAgent = primaryAgents.find((agent) => agent.name === "build"); - const fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0]; - - let resolvedAgent: Agent | undefined; - // Helper to validate model exists in providers const validateModel = (providerId: string, modelId: string): boolean => { const provider = providers.find((p) => p.id === providerId); @@ -463,6 +787,14 @@ export const useConfigStore = create()( return provider.models.some((m) => m.id === modelId); }; + // --- Agent Selection --- + // Priority: settings.defaultAgent → build → first primary → first agent + const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode)); + const buildAgent = primaryAgents.find((agent) => agent.name === "build"); + const fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0]; + + let resolvedAgent: Agent = fallbackAgent; + // Track invalid settings to clear const invalidSettings: { defaultModel?: string; defaultAgent?: string } = {}; @@ -477,13 +809,6 @@ export const useConfigStore = create()( } } - // 2. Fall back to default logic - if (!resolvedAgent) { - resolvedAgent = fallbackAgent; - } - - set({ currentAgentName: resolvedAgent.name }); - // --- Model Selection --- // Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle let resolvedProviderId: string | undefined; @@ -524,12 +849,44 @@ export const useConfigStore = create()( } } - if (resolvedProviderId && resolvedModelId) { - set({ - currentProviderId: resolvedProviderId, - currentModelId: resolvedModelId, - }); - } + set((state) => { + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers, + agents: safeAgents, + currentProviderId: "", + currentModelId: "", + currentAgentName: undefined, + selectedProviderId: "", + agentModelSelections: {}, + defaultProviders: {}, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + providers, + agents: safeAgents, + currentAgentName: resolvedAgent.name, + currentProviderId: resolvedProviderId ?? baseSnapshot.currentProviderId, + currentModelId: resolvedModelId ?? baseSnapshot.currentModelId, + }; + + const nextState: Partial = { + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + + if (state.activeDirectoryKey === directoryKey) { + nextState.currentAgentName = resolvedAgent.name; + if (resolvedProviderId && resolvedModelId) { + nextState.currentProviderId = resolvedProviderId; + nextState.currentModelId = resolvedModelId; + } + } + + return nextState; + }); // Clear invalid settings from storage (best-effort cleanup) if (Object.keys(invalidSettings).length > 0) { @@ -552,14 +909,75 @@ export const useConfigStore = create()( } console.error("Failed to load agents:", lastError); - set({ agents: previousAgents }); + + set((state) => { + const providers = state.activeDirectoryKey === directoryKey + ? state.providers + : (state.directoryScoped[directoryKey]?.providers ?? []); + + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers, + agents: [], + currentProviderId: "", + currentModelId: "", + currentAgentName: undefined, + selectedProviderId: "", + agentModelSelections: {}, + defaultProviders: {}, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + providers, + agents: previousAgents, + }; + + const nextState: Partial = { + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + + if (state.activeDirectoryKey === directoryKey) { + nextState.agents = previousAgents; + } + + return nextState; + }); + return false; }, setAgent: (agentName: string | undefined) => { const { agents, providers, settingsDefaultModel } = get(); - set({ currentAgentName: agentName }); + set((state) => { + const directoryKey = state.activeDirectoryKey; + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: state.providers, + agents: state.agents, + currentProviderId: state.currentProviderId, + currentModelId: state.currentModelId, + currentAgentName: state.currentAgentName, + selectedProviderId: state.selectedProviderId, + agentModelSelections: state.agentModelSelections, + defaultProviders: state.defaultProviders, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + currentAgentName: agentName, + }; + + return { + currentAgentName: agentName, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + }); if (agentName && typeof window !== "undefined") { @@ -608,9 +1026,33 @@ export const useConfigStore = create()( if (parsed) { const settingsProvider = providers.find((p) => p.id === parsed.providerId); if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) { - set({ - currentProviderId: parsed.providerId, - currentModelId: parsed.modelId, + set((state) => { + const directoryKey = state.activeDirectoryKey; + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: state.providers, + agents: state.agents, + currentProviderId: state.currentProviderId, + currentModelId: state.currentModelId, + currentAgentName: state.currentAgentName, + selectedProviderId: state.selectedProviderId, + agentModelSelections: state.agentModelSelections, + defaultProviders: state.defaultProviders, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + currentProviderId: parsed.providerId, + currentModelId: parsed.modelId, + }; + + return { + currentProviderId: parsed.providerId, + currentModelId: parsed.modelId, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; }); return; } @@ -625,10 +1067,35 @@ export const useConfigStore = create()( const agentModel = agentProvider.models.find((model) => model.id === agent.model!.modelID); if (agentModel) { - set({ - currentProviderId: agent.model!.providerID, - currentModelId: agent.model!.modelID, - selectedProviderId: agent.model!.providerID, + set((state) => { + const directoryKey = state.activeDirectoryKey; + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: state.providers, + agents: state.agents, + currentProviderId: state.currentProviderId, + currentModelId: state.currentModelId, + currentAgentName: state.currentAgentName, + selectedProviderId: state.selectedProviderId, + agentModelSelections: state.agentModelSelections, + defaultProviders: state.defaultProviders, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + currentProviderId: agent.model!.providerID, + currentModelId: agent.model!.modelID, + selectedProviderId: agent.model!.providerID, + }; + + return { + currentProviderId: agent.model!.providerID, + currentModelId: agent.model!.modelID, + selectedProviderId: agent.model!.providerID, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; }); } } @@ -671,28 +1138,29 @@ export const useConfigStore = create()( initializeApp: async () => { try { - console.log("Starting app initialization..."); + const debug = streamDebugEnabled(); + if (debug) console.log("Starting app initialization..."); const isConnected = await get().checkConnection(); - console.log("Connection check result:", isConnected); + if (debug) console.log("Connection check result:", isConnected); if (!isConnected) { - console.log("Server not connected"); + if (debug) console.log("Server not connected"); set({ isConnected: false }); return; } - console.log("Initializing app..."); + if (debug) console.log("Initializing app..."); await opencodeClient.initApp(); - console.log("Loading providers..."); + if (debug) console.log("Loading providers..."); await get().loadProviders(); - console.log("Loading agents..."); + if (debug) console.log("Loading agents..."); await get().loadAgents(); set({ isInitialized: true, isConnected: true }); - console.log("App initialized successfully"); + if (debug) console.log("App initialized successfully"); } catch (error) { console.error("Failed to initialize app:", error); set({ isInitialized: false, isConnected: false }); @@ -770,3 +1238,17 @@ if (!unsubscribeConfigStoreChanges) { } }); } + +let unsubscribeConfigStoreDirectoryChanges: (() => void) | null = null; + +if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) { + unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => { + const nextKey = toDirectoryKey(state.currentDirectory); + const prevKey = toDirectoryKey(prevState.currentDirectory); + if (nextKey === prevKey) { + return; + } + + void useConfigStore.getState().activateDirectory(state.currentDirectory); + }); +} diff --git a/packages/ui/src/stores/useDirectoryStore.ts b/packages/ui/src/stores/useDirectoryStore.ts index 345f5c4b..f9fd8ac6 100644 --- a/packages/ui/src/stores/useDirectoryStore.ts +++ b/packages/ui/src/stores/useDirectoryStore.ts @@ -1,15 +1,9 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; -import type { DirectorySwitchResult } from '@/lib/opencode/client'; import { getDesktopHomeDirectory } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; -import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore'; -import { useCommandsStore } from '@/stores/useCommandsStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; -import { emitConfigChange } from '@/lib/configSync'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { getSafeStorage } from './utils/safeStorage'; @@ -37,89 +31,6 @@ const persistedLastDirectory = safeStorage.getItem('lastDirectory'); const initialHasPersistedDirectory = typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0; -const notifyOpenCodeWorkingDirectory = (path: string, options?: { showOverlay?: boolean }) => { - const showOverlay = options?.showOverlay ?? true; - if (showOverlay) { - startConfigUpdate('Switching project directory…'); - } - - return opencodeClient.setOpenCodeWorkingDirectory(path).catch((error) => { - console.warn('Failed to synchronize OpenCode working directory:', error); - throw error; - }); -}; - -const scheduleDirectoryFollowUp = ( - restartPromise: Promise, - options: { showOverlay: boolean }, - onComplete?: (result: DirectorySwitchResult | null) => void -) => { - const { showOverlay } = options; - - const reloadSessions = () => { - try { - useSessionStore.getState().loadSessions(); - } catch (err) { - console.error('Failed to reload sessions after directory change:', err); - } - }; - - void (async () => { - let result: DirectorySwitchResult | null = null; - - try { - result = await restartPromise; - } catch (error) { - console.error('Failed to update OpenCode working directory:', error); - if (showOverlay) { - updateConfigUpdateMessage('Failed to switch directory. Please try again.'); - await new Promise((resolve) => setTimeout(resolve, 1500)); - finishConfigUpdate(); - } - onComplete?.(result); - reloadSessions(); - return; - } - - try { - if (result && result.restarted) { - try { - if (typeof window !== 'undefined' && window.localStorage) { - window.localStorage.removeItem('commands-store'); - } - } catch (storageError) { - console.warn('Failed to reset commands-store cache:', storageError); - } - - await refreshAfterOpenCodeRestart({ message: 'Refreshing OpenCode configuration…' }); - - try { - await useCommandsStore.getState().loadCommands(); - - try { - emitConfigChange('commands', { source: 'useCommandsStore' }); - } catch (syncError) { - console.warn('Failed to emit command configuration change:', syncError); - } - } catch (commandError) { - console.warn('Failed to reload commands after directory change:', commandError); - } - } else if (showOverlay) { - finishConfigUpdate(); - } - } catch (error) { - console.error('Failed to refresh configuration after directory change:', error); - if (showOverlay) { - updateConfigUpdateMessage('Failed to refresh configuration. Please reload manually.'); - await new Promise((resolve) => setTimeout(resolve, 1500)); - finishConfigUpdate(); - } - } finally { - onComplete?.(result); - reloadSessions(); - } - })(); -}; const invalidateFileSearchCache = (scope?: string | null) => { try { @@ -295,26 +206,20 @@ export const useDirectoryStore = create()( isSwitchingDirectory: false, setDirectory: (path: string, options?: { showOverlay?: boolean }) => { + void options; const homeDir = cachedHomeDirectory || get().homeDirectory || safeStorage.getItem('homeDirectory'); const resolvedPath = resolveDirectoryPath(path, homeDir); if (streamDebugEnabled()) { console.log('[DirectoryStore] setDirectory called with path:', resolvedPath); } - const showOverlay = options?.showOverlay ?? true; opencodeClient.setDirectory(resolvedPath); invalidateFileSearchCache(); - const restartPromise = notifyOpenCodeWorkingDirectory(resolvedPath, { showOverlay }); - if (streamDebugEnabled()) { - console.log('[DirectoryStore] notifyOpenCodeWorkingDirectory initiated'); - } set((state) => { - const newHistory = [...state.directoryHistory.slice(0, state.historyIndex + 1), resolvedPath]; safeStorage.setItem('lastDirectory', resolvedPath); - void updateDesktopSettings({ lastDirectory: resolvedPath }); return { @@ -323,21 +228,9 @@ export const useDirectoryStore = create()( historyIndex: newHistory.length - 1, hasPersistedDirectory: true, isHomeReady: true, - isSwitchingDirectory: true, + isSwitchingDirectory: false, }; }); - - scheduleDirectoryFollowUp(restartPromise, { showOverlay }, () => { - set((state) => { - if (state.currentDirectory !== resolvedPath) { - return {}; - } - if (!state.isSwitchingDirectory) { - return {}; - } - return { isSwitchingDirectory: false }; - }); - }); }, goBack: () => { @@ -348,7 +241,6 @@ export const useDirectoryStore = create()( opencodeClient.setDirectory(newDirectory); invalidateFileSearchCache(); - const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory); safeStorage.setItem('lastDirectory', newDirectory); @@ -359,19 +251,7 @@ export const useDirectoryStore = create()( historyIndex: newIndex, hasPersistedDirectory: true, isHomeReady: true, - isSwitchingDirectory: true, - }); - - scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => { - set((state) => { - if (state.currentDirectory !== newDirectory) { - return {}; - } - if (!state.isSwitchingDirectory) { - return {}; - } - return { isSwitchingDirectory: false }; - }); + isSwitchingDirectory: false, }); } }, @@ -384,7 +264,6 @@ export const useDirectoryStore = create()( opencodeClient.setDirectory(newDirectory); invalidateFileSearchCache(); - const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory); safeStorage.setItem('lastDirectory', newDirectory); @@ -395,19 +274,7 @@ export const useDirectoryStore = create()( historyIndex: newIndex, hasPersistedDirectory: true, isHomeReady: true, - isSwitchingDirectory: true, - }); - - scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => { - set((state) => { - if (state.currentDirectory !== newDirectory) { - return {}; - } - if (!state.isSwitchingDirectory) { - return {}; - } - return { isSwitchingDirectory: false }; - }); + isSwitchingDirectory: false, }); } }, @@ -484,12 +351,12 @@ export const useDirectoryStore = create()( updates.currentDirectory = resolvedHome; updates.directoryHistory = [resolvedHome]; updates.historyIndex = 0; - updates.isSwitchingDirectory = true; + updates.isSwitchingDirectory = false; } else if (currentChanged || historyChanged) { updates.currentDirectory = resolvedCurrent as string; updates.directoryHistory = resolvedHistory; updates.historyIndex = Math.min(state.historyIndex, resolvedHistory.length - 1); - updates.isSwitchingDirectory = true; + updates.isSwitchingDirectory = false; } set(() => updates as Partial); @@ -501,18 +368,6 @@ export const useDirectoryStore = create()( safeStorage.setItem('lastDirectory', nextDirectory); void updateDesktopSettings({ lastDirectory: nextDirectory }); - const restartPromise = notifyOpenCodeWorkingDirectory(nextDirectory, { showOverlay: false }); - scheduleDirectoryFollowUp(restartPromise, { showOverlay: false }, () => { - set((state) => { - if (state.currentDirectory !== nextDirectory) { - return {}; - } - if (!state.isSwitchingDirectory) { - return {}; - } - return { isSwitchingDirectory: false }; - }); - }); } void updateDesktopSettings({ homeDirectory: resolvedHome }); diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts index 2e1df9df..71dec7f2 100644 --- a/packages/ui/src/stores/useMultiRunStore.ts +++ b/packages/ui/src/stores/useMultiRunStore.ts @@ -6,6 +6,7 @@ import { createWorktree } from '@/lib/git/worktreeService'; import { checkIsGitRepository } from '@/lib/gitApi'; import { useSessionStore } from './sessionStore'; import { useDirectoryStore } from './useDirectoryStore'; +import { useProjectsStore } from './useProjectsStore'; /** * Generate a git-safe slug from a string. @@ -48,8 +49,33 @@ const sanitizeWorktreeSlug = (value: string): string => { }; -const getCurrentDirectory = (): string | null => { - return useDirectoryStore.getState().currentDirectory ?? null; +const resolveProjectDirectory = (): string | null => { + const projectsState = useProjectsStore.getState(); + const activeProjectId = projectsState.activeProjectId; + const activeProjectPath = activeProjectId + ? projectsState.projects.find((project) => project.id === activeProjectId)?.path + : undefined; + + if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) { + return activeProjectPath; + } + + const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null; + if (!currentDirectory) { + return null; + } + + const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory; + const marker = '/.openchamber/'; + const markerIndex = normalized.indexOf(marker); + if (markerIndex > 0) { + return normalized.slice(0, markerIndex); + } + if (normalized.endsWith('/.openchamber')) { + return normalized.slice(0, normalized.length - '/.openchamber'.length); + } + + return normalized; }; interface MultiRunState { @@ -99,7 +125,7 @@ export const useMultiRunStore = create()( set({ isLoading: true, error: null }); try { - const directory = getCurrentDirectory(); + const directory = resolveProjectDirectory(); if (!directory) { set({ error: 'No directory selected', isLoading: false }); return null; @@ -236,7 +262,7 @@ export const useMultiRunStore = create()( })(); set({ isLoading: false }); - return { sessionIds, firstSessionId }; + return { groupSlug, sessionIds, firstSessionId }; } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to create Multi-Run', diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts new file mode 100644 index 00000000..175daa50 --- /dev/null +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -0,0 +1,447 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import { opencodeClient } from '@/lib/opencode/client'; +import type { ProjectEntry } from '@/lib/api/types'; +import type { DesktopSettings } from '@/lib/desktop'; +import { updateDesktopSettings } from '@/lib/persistence'; +import { getSafeStorage } from './utils/safeStorage'; +import { useDirectoryStore } from './useDirectoryStore'; +import { streamDebugEnabled } from '@/stores/utils/streamDebug'; + +interface ProjectPathValidationResult { + ok: boolean; + normalizedPath?: string; + reason?: string; +} + +interface ProjectsStore { + projects: ProjectEntry[]; + activeProjectId: string | null; + + addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null; + removeProject: (id: string) => void; + setActiveProject: (id: string) => void; + setActiveProjectIdOnly: (id: string) => void; + renameProject: (id: string, label: string) => void; + reorderProjects: (fromIndex: number, toIndex: number) => void; + validateProjectPath: (path: string) => ProjectPathValidationResult; + synchronizeFromSettings: (settings: DesktopSettings) => void; + getActiveProject: () => ProjectEntry | null; +} + +const safeStorage = getSafeStorage(); +const PROJECTS_STORAGE_KEY = 'projects'; +const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId'; + +const resolveTildePath = (value: string, homeDir?: string | null): string => { + const trimmed = value.trim(); + if (!trimmed.startsWith('~')) { + return trimmed; + } + if (!homeDir) { + return trimmed; + } + if (trimmed === '~') { + return homeDir; + } + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return `${homeDir}${trimmed.slice(1)}`; + } + return trimmed; +}; + +const normalizeProjectPath = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + + const homeDirectory = safeStorage.getItem('homeDirectory') || useDirectoryStore.getState().homeDirectory || ''; + const expanded = resolveTildePath(trimmed, homeDirectory); + + const normalized = expanded.replace(/\\/g, '/'); + if (normalized === '/') { + return '/'; + } + return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; +}; + +const deriveProjectLabel = (path: string): string => { + const normalized = normalizeProjectPath(path); + if (!normalized || normalized === '/') { + return 'Root'; + } + const segments = normalized.split('/').filter(Boolean); + return segments[segments.length - 1] || normalized; +}; + +const createProjectId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `proj_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +}; + +const sanitizeProjects = (value: unknown): ProjectEntry[] => { + if (!Array.isArray(value)) { + return []; + } + + const result: ProjectEntry[] = []; + const seenIds = new Set(); + const seenPaths = new Set(); + + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const candidate = entry as Record; + + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; + if (!id || !rawPath) continue; + + const normalizedPath = normalizeProjectPath(rawPath); + if (!normalizedPath) continue; + + if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue; + seenIds.add(id); + seenPaths.add(normalizedPath); + + const project: ProjectEntry = { + id, + path: normalizedPath, + }; + + if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) { + project.label = candidate.label.trim(); + } + if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) { + project.addedAt = candidate.addedAt; + } + if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) { + project.lastOpenedAt = candidate.lastOpenedAt; + } + + result.push(project); + } + + return result; +}; + +const readPersistedProjects = (): ProjectEntry[] => { + try { + const raw = safeStorage.getItem(PROJECTS_STORAGE_KEY); + if (!raw) { + return []; + } + return sanitizeProjects(JSON.parse(raw)); + } catch { + return []; + } +}; + +const readPersistedActiveProjectId = (): string | null => { + try { + const raw = safeStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY); + if (typeof raw === 'string' && raw.trim().length > 0) { + return raw.trim(); + } + } catch { + return null; + } + return null; +}; + +const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => { + try { + safeStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects)); + } catch { + // ignored + } + + try { + if (activeProjectId) { + safeStorage.setItem(ACTIVE_PROJECT_STORAGE_KEY, activeProjectId); + } else { + safeStorage.removeItem(ACTIVE_PROJECT_STORAGE_KEY); + } + } catch { + // ignored + } +}; + +const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null) => { + cacheProjects(projects, activeProjectId); + void updateDesktopSettings({ projects, activeProjectId: activeProjectId ?? undefined }); +}; + +const initialProjects = readPersistedProjects(); +const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectId: string | null } | null => { + if (typeof window === 'undefined') { + return null; + } + + const runtimeApis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }) + .__OPENCHAMBER_RUNTIME_APIS__; + if (!runtimeApis?.runtime?.isVSCode) { + return null; + } + + const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder; + if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) { + return null; + } + + const normalizedPath = normalizeProjectPath(workspaceFolder); + if (!normalizedPath) { + return null; + } + + const id = `vscode:${normalizedPath}`; + const entry: ProjectEntry = { + id, + path: normalizedPath, + label: deriveProjectLabel(normalizedPath), + addedAt: Date.now(), + lastOpenedAt: Date.now(), + }; + + if (streamDebugEnabled()) { + console.log('[OpenChamber][VSCode][projects] Using workspace fallback project', entry); + } + + return { projects: [entry], activeProjectId: id }; +}; + +// VS Code runtime should behave as a single-project environment scoped to the workspace folder. +// Always prefer the workspace project over any persisted multi-project registry. +const vscodeWorkspace = getVSCodeWorkspaceProject(); +const effectiveInitialProjects = vscodeWorkspace?.projects ?? initialProjects; +const initialActiveProjectId = vscodeWorkspace?.activeProjectId + ?? readPersistedActiveProjectId() + ?? effectiveInitialProjects[0]?.id + ?? null; + +if (vscodeWorkspace) { + cacheProjects(effectiveInitialProjects, initialActiveProjectId); +} + +export const useProjectsStore = create()( + devtools((set, get) => ({ + projects: effectiveInitialProjects, + activeProjectId: initialActiveProjectId, + + validateProjectPath: (path: string): ProjectPathValidationResult => { + if (typeof path !== 'string' || path.trim().length === 0) { + return { ok: false, reason: 'Provide a directory path.' }; + } + + const normalized = normalizeProjectPath(path); + if (!normalized) { + return { ok: false, reason: 'Directory path cannot be empty.' }; + } + + return { ok: true, normalizedPath: normalized }; + }, + + addProject: (path: string, options?: { label?: string; id?: string }) => { + if (vscodeWorkspace) { + return null; + } + const { validateProjectPath } = get(); + const validation = validateProjectPath(path); + if (!validation.ok || !validation.normalizedPath) { + return null; + } + + const normalizedPath = validation.normalizedPath; + const existing = get().projects.find((project) => project.path === normalizedPath); + if (existing) { + get().setActiveProject(existing.id); + return existing; + } + + const now = Date.now(); + const label = options?.label?.trim() || deriveProjectLabel(normalizedPath); + const candidateId = options?.id?.trim(); + const id = candidateId && !get().projects.some((project) => project.id === candidateId) + ? candidateId + : createProjectId(); + const entry: ProjectEntry = { + id, + path: normalizedPath, + label, + addedAt: now, + lastOpenedAt: now, + }; + + const nextProjects = [...get().projects, entry]; + set({ projects: nextProjects }); + + if (streamDebugEnabled()) { + console.info('[ProjectsStore] Added project', entry); + } + + get().setActiveProject(entry.id); + return entry; + }, + + removeProject: (id: string) => { + if (vscodeWorkspace) { + return; + } + const current = get(); + const nextProjects = current.projects.filter((project) => project.id !== id); + let nextActiveId = current.activeProjectId; + + if (current.activeProjectId === id) { + nextActiveId = nextProjects[0]?.id ?? null; + } + + set({ projects: nextProjects, activeProjectId: nextActiveId }); + persistProjects(nextProjects, nextActiveId); + + if (nextActiveId) { + const nextActive = nextProjects.find((project) => project.id === nextActiveId); + if (nextActive) { + opencodeClient.setDirectory(nextActive.path); + useDirectoryStore.getState().setDirectory(nextActive.path, { showOverlay: false }); + } + } else { + void useDirectoryStore.getState().goHome(); + } + }, + + setActiveProject: (id: string) => { + if (vscodeWorkspace) { + return; + } + const { projects, activeProjectId } = get(); + if (activeProjectId === id) { + return; + } + const target = projects.find((project) => project.id === id); + if (!target) { + return; + } + + const now = Date.now(); + const nextProjects = projects.map((project) => + project.id === id ? { ...project, lastOpenedAt: now } : project + ); + + set({ projects: nextProjects, activeProjectId: id }); + persistProjects(nextProjects, id); + + opencodeClient.setDirectory(target.path); + useDirectoryStore.getState().setDirectory(target.path, { showOverlay: false }); + }, + + setActiveProjectIdOnly: (id: string) => { + if (vscodeWorkspace) { + return; + } + const { projects, activeProjectId } = get(); + if (activeProjectId === id) { + return; + } + const target = projects.find((project) => project.id === id); + if (!target) { + return; + } + + const now = Date.now(); + const nextProjects = projects.map((project) => + project.id === id ? { ...project, lastOpenedAt: now } : project + ); + + set({ projects: nextProjects, activeProjectId: id }); + persistProjects(nextProjects, id); + }, + + renameProject: (id: string, label: string) => { + if (vscodeWorkspace) { + return; + } + const trimmed = label.trim(); + if (!trimmed) { + return; + } + + const { projects, activeProjectId } = get(); + const nextProjects = projects.map((project) => + project.id === id ? { ...project, label: trimmed } : project + ); + set({ projects: nextProjects }); + persistProjects(nextProjects, activeProjectId); + }, + + reorderProjects: (fromIndex: number, toIndex: number) => { + if (vscodeWorkspace) { + return; + } + const { projects, activeProjectId } = get(); + if ( + fromIndex < 0 || + fromIndex >= projects.length || + toIndex < 0 || + toIndex >= projects.length || + fromIndex === toIndex + ) { + return; + } + + const nextProjects = [...projects]; + const [moved] = nextProjects.splice(fromIndex, 1); + nextProjects.splice(toIndex, 0, moved); + + set({ projects: nextProjects }); + persistProjects(nextProjects, activeProjectId); + }, + + synchronizeFromSettings: (settings: DesktopSettings) => { + if (vscodeWorkspace) { + return; + } + const incomingProjects = sanitizeProjects(settings.projects ?? []); + const incomingActive = typeof settings.activeProjectId === 'string' && settings.activeProjectId.trim() + ? settings.activeProjectId.trim() + : null; + + const current = get(); + const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects); + const activeChanged = current.activeProjectId !== incomingActive; + + if (!projectsChanged && !activeChanged) { + return; + } + + set({ projects: incomingProjects, activeProjectId: incomingActive }); + cacheProjects(incomingProjects, incomingActive); + + if (incomingActive) { + const activeProject = incomingProjects.find((project) => project.id === incomingActive); + if (activeProject) { + opencodeClient.setDirectory(activeProject.path); + useDirectoryStore.getState().setDirectory(activeProject.path, { showOverlay: false }); + } + } + }, + + getActiveProject: () => { + const { projects, activeProjectId } = get(); + if (!activeProjectId) { + return null; + } + return projects.find((project) => project.id === activeProjectId) ?? null; + }, + }), { name: 'projects-store' }) +); + +if (typeof window !== 'undefined') { + window.addEventListener('openchamber:settings-synced', (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail && typeof detail === 'object') { + useProjectsStore.getState().synchronizeFromSettings(detail); + } + }); +} diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index 59102b1c..3cec172a 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; import { devtools } from "zustand/middleware"; import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; -import type { Permission, PermissionResponse } from "@/types/permission"; +import type { PermissionRequest, PermissionResponse } from "@/types/permission"; import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes"; import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } from "./types/sessionTypes"; @@ -66,6 +66,7 @@ export const useSessionStore = create()( (set, get) => ({ sessions: [], + sessionsByDirectory: new Map(), currentSessionId: null, lastLoadedDirectory: null, messages: new Map(), @@ -87,6 +88,7 @@ export const useSessionStore = create()( webUICreatedSessions: new Set(), worktreeMetadata: new Map(), availableWorktrees: [], + availableWorktreesByProject: new Map(), currentAgentContext: new Map(), sessionContextUsage: new Map(), sessionAgentEditModes: new Map(), @@ -420,7 +422,7 @@ export const useSessionStore = create()( markMessageStreamSettled: (messageId: string) => useMessageStore.getState().markMessageStreamSettled(messageId), updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo), updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null), - addPermission: (permission: Permission) => { + addPermission: (permission: PermissionRequest) => { const contextData = { currentAgentContext: useContextStore.getState().currentAgentContext, sessionAgentSelections: useContextStore.getState().sessionAgentSelections, @@ -428,9 +430,10 @@ export const useSessionStore = create()( }; return usePermissionStore.getState().addPermission(permission, contextData); }, - respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, permissionId, response), + respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, requestId, response), clearError: () => useSessionManagementStore.getState().clearError(), getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory), + getDirectoryForSession: (sessionId: string) => useSessionManagementStore.getState().getDirectoryForSession(sessionId), getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId), getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId), syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => useMessageStore.getState().syncMessages(sessionId, messages), @@ -507,6 +510,7 @@ export const useSessionStore = create()( return useContextStore.getState().pollForTokenUpdates(sessionId, messageId, messages, maxAttempts); }, updateSession: (session: Session) => useSessionManagementStore.getState().updateSession(session), + removeSessionFromStore: (sessionId: string) => useSessionManagementStore.getState().removeSessionFromStore(sessionId), revertToMessage: async (sessionId: string, messageId: string) => { // Get the message text before reverting @@ -559,7 +563,7 @@ export const useSessionStore = create()( const sessions = get().sessions; const currentSession = sessions.find(s => s.id === sessionId); - // Silent no-op like OpenCode CLI + // No-op when there is nothing to undo/redo if (userMessages.length === 0) { return; } @@ -576,7 +580,7 @@ export const useSessionStore = create()( targetMessage = userMessages[userMessages.length - 1]; } - // Silent no-op like OpenCode CLI + // No-op when there is nothing to undo/redo if (!targetMessage) { return; } @@ -598,7 +602,7 @@ export const useSessionStore = create()( const currentSession = sessions.find(s => s.id === sessionId); const revertToId = currentSession?.revert?.messageID; - // Silent no-op like OpenCode CLI + // No-op when there is nothing to undo/redo if (!revertToId) { return; } @@ -708,13 +712,15 @@ useSessionManagementStore.subscribe((state, prevState) => { if ( state.sessions === prevState.sessions && + state.sessionsByDirectory === prevState.sessionsByDirectory && state.currentSessionId === prevState.currentSessionId && state.lastLoadedDirectory === prevState.lastLoadedDirectory && state.isLoading === prevState.isLoading && state.error === prevState.error && state.webUICreatedSessions === prevState.webUICreatedSessions && state.worktreeMetadata === prevState.worktreeMetadata && - state.availableWorktrees === prevState.availableWorktrees + state.availableWorktrees === prevState.availableWorktrees && + state.availableWorktreesByProject === prevState.availableWorktreesByProject ) { return; } @@ -723,6 +729,7 @@ useSessionManagementStore.subscribe((state, prevState) => { useSessionStore.setState({ sessions: state.sessions, + sessionsByDirectory: state.sessionsByDirectory, currentSessionId: draftOpen ? null : state.currentSessionId, lastLoadedDirectory: state.lastLoadedDirectory, isLoading: state.isLoading, @@ -730,6 +737,7 @@ useSessionManagementStore.subscribe((state, prevState) => { webUICreatedSessions: state.webUICreatedSessions, worktreeMetadata: state.worktreeMetadata, availableWorktrees: state.availableWorktrees, + availableWorktreesByProject: state.availableWorktreesByProject, }); }); @@ -873,6 +881,7 @@ useSessionStore.setState({ webUICreatedSessions: useSessionManagementStore.getState().webUICreatedSessions, worktreeMetadata: useSessionManagementStore.getState().worktreeMetadata, availableWorktrees: useSessionManagementStore.getState().availableWorktrees, + availableWorktreesByProject: useSessionManagementStore.getState().availableWorktreesByProject, messages: useMessageStore.getState().messages, sessionMemoryState: useMessageStore.getState().sessionMemoryState, messageStreamStates: useMessageStore.getState().messageStreamStates, diff --git a/packages/ui/src/stores/useSkillsCatalogStore.ts b/packages/ui/src/stores/useSkillsCatalogStore.ts index 2f5624e8..d7ffc5a4 100644 --- a/packages/ui/src/stores/useSkillsCatalogStore.ts +++ b/packages/ui/src/stores/useSkillsCatalogStore.ts @@ -13,8 +13,14 @@ import type { } from '@/lib/api/types'; import { useSkillsStore } from '@/stores/useSkillsStore'; +import { opencodeClient } from '@/lib/opencode/client'; const getCurrentDirectory = (): string | null => { + const opencodeDirectory = opencodeClient.getDirectory(); + if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) { + return opencodeDirectory; + } + try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const store = (window as any).__zustand_directory_store__; @@ -24,6 +30,7 @@ const getCurrentDirectory = (): string | null => { } catch { // ignore } + return null; }; diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 0870cb5b..5e45a93f 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -8,8 +8,14 @@ import { } from "@/lib/configUpdate"; import { getSafeStorage } from "./utils/safeStorage"; -// Access directory store without circular dependency +import { opencodeClient } from '@/lib/opencode/client'; + const getCurrentDirectory = (): string | null => { + const opencodeDirectory = opencodeClient.getDirectory(); + if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) { + return opencodeDirectory; + } + try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const store = (window as any).__zustand_directory_store__; @@ -19,6 +25,7 @@ const getCurrentDirectory = (): string | null => { } catch { // ignore } + return null; }; diff --git a/packages/ui/src/stores/useTodoStore.ts b/packages/ui/src/stores/useTodoStore.ts index e9cc4139..4e1818a8 100644 --- a/packages/ui/src/stores/useTodoStore.ts +++ b/packages/ui/src/stores/useTodoStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { devtools } from "zustand/middleware"; import { opencodeClient } from "@/lib/opencode/client"; +import { useSessionStore } from "./useSessionStore"; export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; export type TodoPriority = "high" | "medium" | "low"; @@ -46,7 +47,10 @@ export const useTodoStore = create()( set({ isLoading: true }); try { - const rawTodos = await opencodeClient.getSessionTodos(sessionId); + const directory = useSessionStore.getState().getDirectoryForSession(sessionId); + const rawTodos = directory + ? await opencodeClient.withDirectory(directory, () => opencodeClient.getSessionTodos(sessionId)) + : await opencodeClient.getSessionTodos(sessionId); const todos = rawTodos.map(normalizeTodo); set((state) => { diff --git a/packages/ui/src/stores/utils/permissionUtils.ts b/packages/ui/src/stores/utils/permissionUtils.ts index 72bfe5ef..57fb74d4 100644 --- a/packages/ui/src/stores/utils/permissionUtils.ts +++ b/packages/ui/src/stores/utils/permissionUtils.ts @@ -15,14 +15,33 @@ export const isEditPermissionType = (type?: string | null): boolean => { return EDIT_PERMISSION_TOOL_NAMES.has(type.toLowerCase()); }; -const resolveConfigStore = () => { +type PermissionAction = 'allow' | 'deny' | 'ask'; + +type PermissionRule = { + permission: string; + pattern: string; + action: PermissionAction; +}; + +type ConfigStoreAgent = { + name: string; + permission?: PermissionRule[]; +}; + +type ConfigStoreState = { + agents?: ConfigStoreAgent[]; +}; + +type ConfigStoreRef = { getState?: () => ConfigStoreState }; + +const resolveConfigStore = (): ConfigStoreRef | undefined => { if (typeof window === 'undefined') { return undefined; } - return (window as { __zustand_config_store__?: { getState?: () => { agents?: Array<{ name: string; permission?: { edit?: string }; tools?: { edit?: boolean } }> } } }).__zustand_config_store__; + return (window as { __zustand_config_store__?: ConfigStoreRef }).__zustand_config_store__; }; -const getAgentDefinition = (agentName?: string): { name: string; permission?: { edit?: string }; tools?: { edit?: boolean } } | undefined => { +const getAgentDefinition = (agentName?: string): ConfigStoreAgent | undefined => { if (!agentName) { return undefined; } @@ -31,24 +50,45 @@ const getAgentDefinition = (agentName?: string): { name: string; permission?: { const configStore = resolveConfigStore(); if (configStore?.getState) { const state = configStore.getState(); - return state.agents?.find?.((agent: { name: string; permission?: { edit?: string }; tools?: { edit?: boolean } }) => agent.name === agentName); + return state.agents?.find?.((agent) => agent.name === agentName); } - } catch { /* ignored */ } + } catch { + /* ignored */ + } return undefined; }; +const resolvePermissionAction = (ruleset: PermissionRule[] | undefined, permission: string): PermissionAction => { + if (!ruleset || ruleset.length === 0) { + return 'ask'; + } + + // Prefer explicit rule for the tool at wildcard pattern. + for (let index = ruleset.length - 1; index >= 0; index -= 1) { + const rule = ruleset[index]; + if (rule.permission === permission && rule.pattern === '*') { + return rule.action; + } + } + + // Fall back to global wildcard. + for (let index = ruleset.length - 1; index >= 0; index -= 1) { + const rule = ruleset[index]; + if (rule.permission === '*' && rule.pattern === '*') { + return rule.action; + } + } + + return 'ask'; +}; + export const getAgentDefaultEditPermission = (agentName?: string): EditPermissionMode => { const agent = getAgentDefinition(agentName); if (!agent) { return 'ask'; } - const permission = agent.permission?.edit; - if (permission === 'allow' || permission === 'ask' || permission === 'deny' || permission === 'full') { - return permission; - } - - const editToolEnabled = agent.tools ? agent.tools.edit !== false : true; - return editToolEnabled ? 'ask' : 'deny'; + const action = resolvePermissionAction(agent.permission, 'edit'); + return action; }; \ No newline at end of file diff --git a/packages/ui/src/types/multirun.ts b/packages/ui/src/types/multirun.ts index e53dd481..37f6a753 100644 --- a/packages/ui/src/types/multirun.ts +++ b/packages/ui/src/types/multirun.ts @@ -36,6 +36,8 @@ export interface CreateMultiRunParams { } export interface CreateMultiRunResult { + /** Canonical group slug used in session titles */ + groupSlug: string; /** Session IDs created successfully (in selection order) */ sessionIds: string[]; /** First successfully created session ID, if any */ diff --git a/packages/ui/src/types/permission.ts b/packages/ui/src/types/permission.ts index 1f157cb6..7cf65084 100644 --- a/packages/ui/src/types/permission.ts +++ b/packages/ui/src/types/permission.ts @@ -1,26 +1,28 @@ -export interface Permission { +export interface PermissionRequest { id: string; - type: string; - pattern?: string | string[]; - patterns?: string[]; // New system: array of specific patterns requesting approval - always?: string[]; // New system: what will be auto-approved on "always" click sessionID: string; - messageID: string; - callID?: string; - title: string; + permission: string; + patterns: string[]; metadata: Record; - time: { - created: number; - }; + always: string[]; tool?: { messageID: string; callID: string; }; } -export interface PermissionEvent { - type: 'permission.updated'; - properties: Permission; +export type PermissionResponse = 'once' | 'always' | 'reject'; + +export interface PermissionAskedEvent { + type: 'permission.asked'; + properties: PermissionRequest; } -export type PermissionResponse = 'once' | 'always' | 'reject'; \ No newline at end of file +export interface PermissionRepliedEvent { + type: 'permission.replied'; + properties: { + sessionID: string; + requestID: string; + reply: PermissionResponse; + }; +} \ No newline at end of file diff --git a/packages/vscode/package.json b/packages/vscode/package.json index b13e7225..1d6bdb29 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -197,7 +197,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.0.209", + "@opencode-ai/sdk": "^1.1.1", "jsonc-parser": "^3.3.1", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index aa820682..0e3bc457 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -15,6 +15,7 @@ export class AgentManagerPanelProvider { private _cachedError?: string; private _sseCounter = 0; private _sseStreams = new Map(); + private _sseHeartbeats = new Map>(); constructor( private readonly _context: vscode.ExtensionContext, @@ -58,6 +59,12 @@ export class AgentManagerPanelProvider { controller.abort(); } this._sseStreams.clear(); + + for (const heartbeat of this._sseHeartbeats.values()) { + clearInterval(heartbeat); + } + this._sseHeartbeats.clear(); + this._panel = undefined; }, null, this._context.subscriptions); @@ -162,12 +169,29 @@ export class AgentManagerPanelProvider { const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); let response: Response; + let wrapAsGlobal = false; + + const requestHeaders = this._buildSseHeaders(headers || {}); + try { response = await fetch(targetUrl, { method: 'GET', - headers: this._buildSseHeaders(headers || {}), + headers: requestHeaders, signal: controller.signal, }); + + // Fallback for OpenCode versions without /global/event. + if ((!response.ok || !response.body) && normalizedPath === '/global/event') { + const fallbackUrl = new URL('event', base).toString(); + response = await fetch(fallbackUrl, { + method: 'GET', + headers: requestHeaders, + signal: controller.signal, + }); + if (response.ok && response.body) { + wrapAsGlobal = true; + } + } } catch (error) { const message = error instanceof Error ? error.message : String(error); return { @@ -196,6 +220,21 @@ export class AgentManagerPanelProvider { this._sseStreams.set(streamId, controller); + const fallbackDirectory = this._openCodeManager?.getWorkingDirectory() + || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + || 'global'; + + if (shouldInjectActivity) { + const heartbeatTimer = setInterval(() => { + if (controller.signal.aborted) { + return; + } + const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`; + this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk }); + }, 30000); + this._sseHeartbeats.set(streamId, heartbeatTimer); + } + (async () => { try { const reader = responseBody.getReader(); @@ -216,7 +255,10 @@ export class AgentManagerPanelProvider { const blocks = sseBuffer.split('\n\n'); sseBuffer = blocks.pop() ?? ''; if (blocks.length > 0) { - const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(blocks) : blocks; + const routedBlocks = wrapAsGlobal + ? wrapSseBlocksAsGlobal(blocks, fallbackDirectory) + : blocks; + const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks; const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join(''); this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); } @@ -229,7 +271,10 @@ export class AgentManagerPanelProvider { } if (sseBuffer) { if (shouldInjectActivity) { - const outboundBlocks = expandSseBlocksWithActivity([sseBuffer]); + const baseBlocks = wrapAsGlobal + ? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory) + : [sseBuffer]; + const outboundBlocks = expandSseBlocksWithActivity(baseBlocks); const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join(''); this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); } else { @@ -252,6 +297,11 @@ export class AgentManagerPanelProvider { } } finally { this._sseStreams.delete(streamId); + const heartbeat = this._sseHeartbeats.get(streamId); + if (heartbeat) { + clearInterval(heartbeat); + this._sseHeartbeats.delete(streamId); + } } })(); @@ -276,6 +326,12 @@ export class AgentManagerPanelProvider { controller.abort(); this._sseStreams.delete(streamId); } + + const heartbeat = this._sseHeartbeats.get(streamId); + if (heartbeat) { + clearInterval(heartbeat); + this._sseHeartbeats.delete(streamId); + } } return { id, type, success: true, data: { stopped: true } }; } @@ -396,6 +452,80 @@ const buildActivityEventBlock = (activity: SessionActivity): string => { })}`; }; +const buildHeartbeatEventBlock = (): string => { + return `data: ${JSON.stringify({ type: 'openchamber:heartbeat', timestamp: Date.now() })}`; +}; + +const parseSseBlockForGlobalWrap = (block: string): { id?: string; payload: Record } | null => { + if (!block) { + return null; + } + + const lines = block.split('\n'); + const dataLines: string[] = []; + let eventId: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).replace(/^\s/, '')); + continue; + } + if (line.startsWith('id:')) { + const candidate = line.slice(3).trim(); + if (candidate) { + eventId = candidate; + } + } + } + + if (dataLines.length === 0) { + return null; + } + + const payloadText = dataLines.join('\n').trim(); + if (!payloadText) { + return null; + } + + try { + const parsed = JSON.parse(payloadText) as unknown; + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const record = parsed as Record; + const nestedPayload = record.payload; + const payload = nestedPayload && typeof nestedPayload === 'object' + ? (nestedPayload as Record) + : record; + + return eventId ? { id: eventId, payload } : { payload }; + } catch { + return null; + } +}; + +const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => { + const normalizedDirectory = typeof directory === 'string' && directory.trim().length > 0 + ? directory.trim().replace(/\\/g, '/') + : 'global'; + + return blocks.map((block) => { + const parsed = parseSseBlockForGlobalWrap(block); + if (!parsed) { + return block; + } + + const envelope = { + directory: normalizedDirectory, + payload: parsed.payload, + }; + + const idPrefix = parsed.id ? `id: ${parsed.id}\n` : ''; + return `${idPrefix}data: ${JSON.stringify(envelope)}`; + }); +}; + const expandSseBlocksWithActivity = (blocks: string[]): string[] => { const expanded: string[] = []; for (const block of blocks) { diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index 0f28d883..7c01a5f4 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -19,6 +19,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private _cachedError?: string; private _sseCounter = 0; private _sseStreams = new Map(); + private _sseHeartbeats = new Map>(); constructor( private readonly _context: vscode.ExtensionContext, @@ -195,12 +196,30 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); let response: Response; + let wrapAsGlobal = false; + + const requestHeaders = this._buildSseHeaders(headers || {}); + try { response = await fetch(targetUrl, { method: 'GET', - headers: this._buildSseHeaders(headers || {}), + headers: requestHeaders, signal: controller.signal, }); + + // Fallback: OpenCode versions without /global/event. + // VS Code is single-workspace, so we can wrap /event into { directory, payload }. + if ((!response.ok || !response.body) && normalizedPath === '/global/event') { + const fallbackUrl = new URL('event', base).toString(); + response = await fetch(fallbackUrl, { + method: 'GET', + headers: requestHeaders, + signal: controller.signal, + }); + if (response.ok && response.body) { + wrapAsGlobal = true; + } + } } catch (error) { const message = error instanceof Error ? error.message : String(error); return { @@ -229,6 +248,21 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this._sseStreams.set(streamId, controller); + const fallbackDirectory = this._openCodeManager?.getWorkingDirectory() + || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + || 'global'; + + if (shouldInjectActivity) { + const heartbeatTimer = setInterval(() => { + if (controller.signal.aborted) { + return; + } + const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`; + this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk }); + }, 30000); + this._sseHeartbeats.set(streamId, heartbeatTimer); + } + (async () => { try { const reader = responseBody.getReader(); @@ -251,7 +285,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const blocks = sseBuffer.split('\n\n'); sseBuffer = blocks.pop() ?? ''; if (blocks.length > 0) { - const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(blocks) : blocks; + const routedBlocks = wrapAsGlobal + ? wrapSseBlocksAsGlobal(blocks, fallbackDirectory) + : blocks; + const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks; const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join(''); this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); } @@ -264,7 +301,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } if (sseBuffer) { if (shouldInjectActivity) { - const outboundBlocks = expandSseBlocksWithActivity([sseBuffer]); + const baseBlocks = wrapAsGlobal + ? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory) + : [sseBuffer]; + const outboundBlocks = expandSseBlocksWithActivity(baseBlocks); const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join(''); this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); } else { @@ -287,6 +327,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } } finally { this._sseStreams.delete(streamId); + const heartbeat = this._sseHeartbeats.get(streamId); + if (heartbeat) { + clearInterval(heartbeat); + this._sseHeartbeats.delete(streamId); + } } })(); @@ -311,6 +356,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { controller.abort(); this._sseStreams.delete(streamId); } + + const heartbeat = this._sseHeartbeats.get(streamId); + if (heartbeat) { + clearInterval(heartbeat); + this._sseHeartbeats.delete(streamId); + } } return { id, type, success: true, data: { stopped: true } }; } @@ -432,6 +483,80 @@ const buildActivityEventBlock = (activity: SessionActivity): string => { })}`; }; +const buildHeartbeatEventBlock = (): string => { + return `data: ${JSON.stringify({ type: 'openchamber:heartbeat', timestamp: Date.now() })}`; +}; + +const parseSseBlockForGlobalWrap = (block: string): { id?: string; payload: Record } | null => { + if (!block) { + return null; + } + + const lines = block.split('\n'); + const dataLines: string[] = []; + let eventId: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).replace(/^\s/, '')); + continue; + } + if (line.startsWith('id:')) { + const candidate = line.slice(3).trim(); + if (candidate) { + eventId = candidate; + } + } + } + + if (dataLines.length === 0) { + return null; + } + + const payloadText = dataLines.join('\n').trim(); + if (!payloadText) { + return null; + } + + try { + const parsed = JSON.parse(payloadText) as unknown; + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const record = parsed as Record; + const nestedPayload = record.payload; + const payload = nestedPayload && typeof nestedPayload === 'object' + ? (nestedPayload as Record) + : record; + + return eventId ? { id: eventId, payload } : { payload }; + } catch { + return null; + } +}; + +const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => { + const normalizedDirectory = typeof directory === 'string' && directory.trim().length > 0 + ? directory.trim().replace(/\\/g, '/') + : 'global'; + + return blocks.map((block) => { + const parsed = parseSseBlockForGlobalWrap(block); + if (!parsed) { + return block; + } + + const envelope = { + directory: normalizedDirectory, + payload: parsed.payload, + }; + + const idPrefix = parsed.id ? `id: ${parsed.id}\n` : ''; + return `${idPrefix}data: ${JSON.stringify(envelope)}`; + }); +}; + const expandSseBlocksWithActivity = (blocks: string[]): string[] => { const expanded: string[] = []; for (const block of blocks) { diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 796e5de5..480b1cd0 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -618,23 +618,28 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } case 'api:config/agents': { - const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; + const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; const agentName = typeof name === 'string' ? name.trim() : ''; if (!agentName) { return { id, type, success: false, error: 'Agent name is required' }; } - // Get working directory for project-level agent support - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + // Use directory from request if provided, otherwise fall back to workspace + const workingDirectory = (typeof directory === 'string' && directory.trim()) + ? directory.trim() + : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; if (normalizedMethod === 'GET') { const sources = getAgentSources(agentName, workingDirectory); + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); return { id, type, success: true, - data: { name: agentName, sources, scope: sources.md.scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, + data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, }; } @@ -693,23 +698,28 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } case 'api:config/commands': { - const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; + const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; const commandName = typeof name === 'string' ? name.trim() : ''; if (!commandName) { return { id, type, success: false, error: 'Command name is required' }; } - // Get working directory for project-level command support - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + // Use directory from request if provided, otherwise fall back to workspace + const workingDirectory = (typeof directory === 'string' && directory.trim()) + ? directory.trim() + : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; if (normalizedMethod === 'GET') { const sources = getCommandSources(commandName, workingDirectory); + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); return { id, type, success: true, - data: { name: commandName, sources, scope: sources.md.scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, + data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, }; } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 2549d779..da898e2c 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -305,9 +305,10 @@ export async function activate(context: vscode.ExtensionContext) { }); const elapsedMs = Date.now() - startedAt; const contentType = resp.headers.get('content-type') || ''; + const isJson = contentType.toLowerCase().includes('json') && !contentType.toLowerCase().includes('text/html'); let summary = ''; - if (contentType.includes('application/json')) { + if (isJson) { const json = await resp.json().catch(() => null); if (Array.isArray(json)) { summary = `json[array] len=${json.length}`; @@ -321,7 +322,7 @@ export async function activate(context: vscode.ExtensionContext) { summary = contentType ? `content-type=${contentType}` : 'no content-type'; } - return { ok: resp.ok, status: resp.status, elapsedMs, summary }; + return { ok: resp.ok && isJson, status: resp.status, elapsedMs, summary }; } catch (error) { const elapsedMs = Date.now() - startedAt; const isAbort = @@ -356,6 +357,8 @@ export async function activate(context: vscode.ExtensionContext) { { label: 'commands', path: '/command', includeDirectory: true }, { label: 'project', path: '/project/current', includeDirectory: true }, { label: 'path', path: '/path', includeDirectory: true }, + // Session listing is what powers the sidebar. This helps diagnose "no sessions shown" bugs. + { label: 'sessions', path: '/session', includeDirectory: true, timeoutMs: 8000 }, { label: 'sessionStatus', path: '/session/status', includeDirectory: true }, ]; diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index ae0f1264..c2bad2ca 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -1,15 +1,16 @@ import * as vscode from 'vscode'; import { spawn, ChildProcess, spawnSync } from 'child_process'; import * as fs from 'fs'; +import * as net from 'net'; import * as path from 'path'; import * as os from 'os'; // Optimized timeouts for faster startup -const PORT_DETECTION_TIMEOUT_MS = 10000; -const READY_CHECK_TIMEOUT_MS = 12000; -const READY_CHECK_INTERVAL_MS = 100; // Fast polling during startup +const READY_CHECK_TIMEOUT_MS = 30000; +const READY_CHECK_INTERVAL_MS = 250; // Avoid hammering the server during startup const HEALTH_CHECK_INTERVAL_MS = 5000; const SHUTDOWN_TIMEOUT_MS = 3000; +const DEFAULT_OPENCODE_PORT = 4096; // Regex to detect port from CLI output (matches desktop pattern) const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi; @@ -65,13 +66,35 @@ export interface OpenCodeManager { function isExecutable(filePath: string): boolean { try { + const stats = fs.statSync(filePath); + if (!stats.isFile()) return false; + if (process.platform === 'win32') return true; fs.accessSync(filePath, fs.constants.X_OK); - return fs.statSync(filePath).isFile(); + return true; } catch { return false; } } +function resolveBinaryFromPath(binaryName: string, searchPath: string): string | null { + if (!binaryName) return null; + if (path.isAbsolute(binaryName)) { + return isExecutable(binaryName) ? binaryName : null; + } + const directories = searchPath.split(path.delimiter).filter(Boolean); + for (const directory of directories) { + try { + const candidate = path.join(directory, binaryName); + if (isExecutable(candidate)) { + return candidate; + } + } catch { + // ignore + } + } + return null; +} + function getLoginShellPath(): string | null { if (process.platform === 'win32') { return null; @@ -126,17 +149,47 @@ function buildAugmentedPath(): string { function resolveCliPath(): string | null { // First check explicit candidates for (const candidate of BIN_CANDIDATES) { - if (candidate && isExecutable(candidate)) { + if (!candidate) continue; + if (isExecutable(candidate)) { return candidate; } + if (process.platform === 'win32' && !candidate.toLowerCase().endsWith('.exe')) { + const withExe = `${candidate}.exe`; + if (isExecutable(withExe)) { + return withExe; + } + } } // Then search in augmented PATH const augmentedPath = buildAugmentedPath(); - for (const segment of augmentedPath.split(path.delimiter)) { - const candidate = path.join(segment, 'opencode'); - if (isExecutable(candidate)) { - return candidate; + if (process.platform === 'win32') { + try { + const result = spawnSync('where', ['opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PATH: augmentedPath }, + }); + if (result.status === 0 && typeof result.stdout === 'string') { + const lines = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + for (const line of lines) { + if (isExecutable(line)) { + return line; + } + } + } + } catch { + // ignore + } + + const fromPath = resolveBinaryFromPath('opencode.exe', augmentedPath); + if (fromPath) { + return fromPath; + } + } else { + const fromPath = resolveBinaryFromPath('opencode', augmentedPath); + if (fromPath) { + return fromPath; } } @@ -192,7 +245,9 @@ async function checkHealth(apiUrl: string, quick = false): Promise { signal: controller.signal, headers: { Accept: 'application/json' }, }); - if (response.ok) { + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + const isJson = contentType.includes('json') && !contentType.includes('text/html'); + if (response.ok && isJson) { clearTimeout(timeout); return true; } @@ -208,6 +263,85 @@ async function checkHealth(apiUrl: string, quick = false): Promise { return false; } +const appendDirectoryQuery = (url: string, directory: string | null | undefined): string => { + const dir = typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : null; + if (!dir) return url; + try { + const parsed = new URL(url); + parsed.searchParams.set('directory', dir); + return parsed.toString(); + } catch { + return url; + } +}; + +async function checkReady(apiUrl: string, directory: string | null | undefined, quick = false): Promise { + const normalized = apiUrl.replace(/\/+$/, ''); + const targets: Array<{ path: string; timeoutMs: number }> = [ + { path: '/config', timeoutMs: quick ? 1500 : 4000 }, + { path: '/config/providers', timeoutMs: quick ? 2000 : 6000 }, + { path: '/agent', timeoutMs: quick ? 2500 : 10000 }, + { path: '/session/status', timeoutMs: quick ? 2000 : 6000 }, + ]; + + for (const target of targets) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), target.timeoutMs); + try { + const url = appendDirectoryQuery(`${normalized}${target.path}`, directory); + const response = await fetch(url, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return false; + } + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + const isJson = contentType.includes('json') && !contentType.includes('text/html'); + if (!isJson) { + return false; + } + } catch { + return false; + } finally { + clearTimeout(timeout); + } + } + + return true; +} + +async function isTcpPortAvailable(port: number): Promise { + if (!Number.isFinite(port) || port <= 0) return false; + + return await new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + + server.once('error', () => resolve(false)); + server.listen({ host: '127.0.0.1', port }, () => { + server.close(() => resolve(true)); + }); + }); +} + +async function getEphemeralPort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once('error', (err) => reject(err)); + server.listen({ host: '127.0.0.1', port: 0 }, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate ephemeral port'))); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager { let childProcess: ChildProcess | null = null; @@ -224,7 +358,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo // Port detection state (like desktop) let detectedPort: number | null = null; - let portWaiters: Array<(port: number) => void> = []; // OpenCode API prefix detection (some versions serve under /api) let apiPrefix: string = ''; @@ -280,6 +413,31 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo return `http://localhost:${port}${prefix}`; }; + const probeOpenCodeAtPort = async (port: number, quick = false): Promise => { + if (!Number.isFinite(port) || port <= 0) return null; + const origin = `http://localhost:${port}`; + for (const candidate of API_PREFIX_CANDIDATES) { + const base = `${origin}${candidate}`; + if (await checkReady(base, workingDirectory, quick)) { + return candidate; + } + } + return null; + }; + + const waitForOpenCodeReadyAtPort = async (port: number, timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const prefix = await probeOpenCodeAtPort(port, true); + if (prefix !== null) { + setDetectedApiPrefix(prefix); + return true; + } + await new Promise(r => setTimeout(r, READY_CHECK_INTERVAL_MS)); + } + return false; + }; + const detectApiPrefixFromOutput = (text: string) => { if (!text) return; URL_REGEX.lastIndex = 0; @@ -297,56 +455,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } }; - const extractPrefixFromOpenApiDoc = (content: string): string | null => { - const match = content.match(/__OPENCODE_API_BASE__\s*=\s*['"]([^'"]+)['"]/); - if (!match?.[1]) return null; - try { - const parsed = new URL(match[1], 'http://localhost'); - return normalizeApiPrefix(parsed.pathname || ''); - } catch { - return normalizeApiPrefix(match[1]); - } - }; - - const detectApiPrefix = async (port: number): Promise => { - if (apiPrefixDetected) return apiPrefix; - - const origin = `http://localhost:${port}`; - - // Try /doc for explicit base hints first (best signal when available). - for (const candidate of API_PREFIX_CANDIDATES) { - const prefix = normalizeApiPrefix(candidate); - try { - const response = await fetch(`${origin}${prefix}/doc`, { method: 'GET', headers: { Accept: '*/*' } }); - if (!response.ok) continue; - const text = await response.text(); - const extracted = extractPrefixFromOpenApiDoc(text); - if (extracted !== null) { - setDetectedApiPrefix(extracted); - return apiPrefix; - } - } catch { - // ignore - } - } - - // Fallback: probe a stable endpoint under root vs /api. - for (const candidate of API_PREFIX_CANDIDATES) { - try { - const base = buildApiBaseUrlFromPort(port, candidate); - const response = await fetch(`${base}/config`, { method: 'GET', headers: { Accept: 'application/json' } }); - if (!response.ok) continue; - await response.json().catch(() => null); - setDetectedApiPrefix(candidate); - return apiPrefix; - } catch { - // ignore - } - } - - return apiPrefix; - }; - function setStatus(newStatus: ConnectionStatus, error?: string) { if (status !== newStatus || lastError !== error) { status = newStatus; @@ -359,20 +467,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } function setDetectedPort(port: number) { - if (detectedPort !== port) { - detectedPort = port; - - // Notify all waiters - const waiters = portWaiters; - portWaiters = []; - for (const notify of waiters) { - try { - notify(port); - } catch { - // Ignore waiter errors - } - } - } + detectedPort = port; } function detectPortFromOutput(text: string) { @@ -382,6 +477,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo while ((match = URL_REGEX.exec(text)) !== null) { const port = parseInt(match[1], 10); if (Number.isFinite(port) && port > 0) { + if (detectedPort !== null && detectedPort !== port) { + return; + } setDetectedPort(port); const inferred = inferPrefixFromLogPath(match[2] || ''); if (inferred !== null) { @@ -396,45 +494,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo if (fallbackMatch) { const port = parseInt(fallbackMatch[1], 10); if (Number.isFinite(port) && port > 0) { + if (detectedPort !== null && detectedPort !== port) { + return; + } setDetectedPort(port); } } } - async function waitForPort(timeoutMs: number): Promise { - if (detectedPort !== null) { - return detectedPort; - } - - return new Promise((resolve, reject) => { - const onPortDetected = (port: number) => { - clearTimeout(timeout); - resolve(port); - }; - - const timeout = setTimeout(() => { - portWaiters = portWaiters.filter(cb => cb !== onPortDetected); - reject(new Error('Timed out waiting for OpenCode port detection')); - }, timeoutMs); - - portWaiters.push(onPortDetected); - }); - } - - async function waitForReady(apiUrl: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - // Use quick health check during startup for faster response - if (await checkHealth(apiUrl, true)) { - return true; - } - await new Promise(r => setTimeout(r, READY_CHECK_INTERVAL_MS)); - } - - return false; - } - function getApiUrl(): string | null { if (useConfiguredUrl && configuredApiUrl) { return configuredApiUrl.replace(/\/+$/, ''); @@ -483,7 +550,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo // If user configured an external API URL, do NOT start a local CLI instance. if (useConfiguredUrl && configuredApiUrl) { setStatus('connecting'); - const healthy = await checkHealth(configuredApiUrl); + const healthy = await checkReady(configuredApiUrl, workingDirectory, false); if (healthy) { setStatus('connected'); startHealthCheck(); @@ -495,7 +562,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo // Check for existing running instance (only if port is known) const currentUrl = getApiUrl(); - if (currentUrl && await checkHealth(currentUrl)) { + if (currentUrl && await checkReady(currentUrl, workingDirectory, false)) { setStatus('connected'); startHealthCheck(); return; @@ -524,22 +591,29 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - // Use port 0 for dynamic assignment unless user configured a specific port - const portArg = configuredPort !== null ? configuredPort.toString() : '0'; - try { + const portToUse = await (async () => { + if (await isTcpPortAvailable(DEFAULT_OPENCODE_PORT)) { + return DEFAULT_OPENCODE_PORT; + } + return await getEphemeralPort(); + })(); + const augmentedEnv = { ...process.env, PATH: buildAugmentedPath(), }; - childProcess = spawn(cliPath!, ['serve', '--port', portArg], { + childProcess = spawn(cliPath!, ['serve', '--port', portToUse.toString()], { cwd: spawnCwd, env: augmentedEnv, detached: false, stdio: ['ignore', 'pipe', 'pipe'], }); + // We picked the port explicitly, so we don't need to wait for log-based detection. + setDetectedPort(portToUse); + childProcess.stdout?.on('data', (data) => { const text = data.toString(); detectPortFromOutput(text); @@ -566,34 +640,13 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo lastExitCode = typeof code === 'number' ? code : null; }); - // Wait for port detection (port comes from stdout/stderr) - try { - await waitForPort(PORT_DETECTION_TIMEOUT_MS); - } catch { - setStatus('error', 'OpenCode did not report port in time'); - await stop(); - return; - } - - // Now wait for API to be ready - const detected = detectedPort; - if (detected !== null && !apiPrefixDetected) { - await detectApiPrefix(detected); - } - - const apiUrl = getApiUrl(); - if (!apiUrl) { - setStatus('error', 'Failed to determine OpenCode API URL'); - await stop(); - return; - } - - const ready = await waitForReady(apiUrl, READY_CHECK_TIMEOUT_MS); + const ready = await waitForOpenCodeReadyAtPort(portToUse, READY_CHECK_TIMEOUT_MS); if (ready) { setStatus('connected'); startHealthCheck(); } else { setStatus('error', 'OpenCode API did not become ready in time'); + await stop(); } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -635,6 +688,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo if (target === workingDirectory) { return { success: true, restarted: false, path: target }; } + + // Track requested directory for UI + path resolution. + // OpenCode requests should use the `directory` parameter instead of relying on process cwd. workingDirectory = target; // When pointing at an external API URL, avoid restarting a local CLI process. @@ -642,8 +698,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo return { success: true, restarted: false, path: target }; } - await restart(); - return { success: true, restarted: true, path: target }; + return { success: true, restarted: false, path: target }; } return { diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index f1e1911e..6d6df31d 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -172,9 +172,37 @@ const writePromptFile = (filePath: string, content: string) => { fs.writeFileSync(filePath, content, 'utf8'); }; +/** + * Get all possible project config paths in priority order + * Priority: root > .opencode/, json > jsonc + */ +const getProjectConfigCandidates = (workingDirectory?: string): string[] => { + if (!workingDirectory) return []; + return [ + path.join(workingDirectory, 'opencode.json'), + path.join(workingDirectory, 'opencode.jsonc'), + path.join(workingDirectory, '.opencode', 'opencode.json'), + path.join(workingDirectory, '.opencode', 'opencode.jsonc'), + ]; +}; + +/** + * Find existing project config file or return default path for new config + */ const getProjectConfigPath = (workingDirectory?: string): string | null => { if (!workingDirectory) return null; - return path.join(workingDirectory, 'opencode.json'); + + const candidates = getProjectConfigCandidates(workingDirectory); + + // Return first existing config file + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + // Default to root opencode.json for new configs + return candidates[0] || null; }; const getConfigPaths = (workingDirectory?: string) => ({ @@ -289,7 +317,14 @@ const parseMdFile = (filePath: string): { frontmatter: Record; const content = fs.readFileSync(filePath, 'utf8'); const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!match) return { frontmatter: {}, body: content.trim() }; - return { frontmatter: (yaml.parse(match[1]) || {}) as Record, body: (match[2] || '').trim() }; + let frontmatter: Record = {}; + try { + frontmatter = (yaml.parse(match[1]) || {}) as Record; + } catch (error) { + console.warn(`[OpenChamber][VSCode] Failed to parse frontmatter for ${filePath}, treating as empty:`, error); + frontmatter = {}; + } + return { frontmatter, body: (match[2] || '').trim() }; }; const writeMdFile = (filePath: string, frontmatter: Record, body: string) => { @@ -541,10 +576,11 @@ export const getCommandSources = (commandName: string, workingDirectory?: string const jsonSource = getJsonEntrySource(layers, 'command', commandName); const commandSection = jsonSource.section as Record | undefined; const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath; + const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER; const sources: ConfigSources = { md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] }, - json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, fields: [] }, + json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, scope: jsonSource.exists ? jsonScope : null, fields: [] }, projectMd: { exists: projectExists, path: projectPath }, userMd: { exists: userExists, path: userPath } }; @@ -1141,4 +1177,3 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void throw new Error(`Skill "${skillName}" not found`); } }; - diff --git a/packages/vscode/vite.config.ts b/packages/vscode/vite.config.ts index 8ef4339c..8b6ab8fc 100644 --- a/packages/vscode/vite.config.ts +++ b/packages/vscode/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ define: { 'process.env.NODE_ENV': JSON.stringify('production'), 'global': 'globalThis', + '__OPENCHAMBER_WEBVIEW_BUILD_TIME__': JSON.stringify(new Date().toISOString()), }, envPrefix: ['VITE_'], optimizeDeps: { diff --git a/packages/vscode/webview/api/settings.ts b/packages/vscode/webview/api/settings.ts index 470450f0..63c4b39d 100644 --- a/packages/vscode/webview/api/settings.ts +++ b/packages/vscode/webview/api/settings.ts @@ -1,4 +1,4 @@ -import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '../../../ui/src/lib/api/types'; +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'; diff --git a/packages/vscode/webview/api/vscode.ts b/packages/vscode/webview/api/vscode.ts index b3fab89a..5e3232d0 100644 --- a/packages/vscode/webview/api/vscode.ts +++ b/packages/vscode/webview/api/vscode.ts @@ -1,4 +1,4 @@ -import type { VSCodeAPI } from '../../../ui/src/lib/api/types'; +import type { VSCodeAPI } from '@openchamber/ui/lib/api/types'; import { executeVSCodeCommand } from './bridge'; export const createVSCodeActionsAPI = (): VSCodeAPI => ({ diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index b5846642..ed888fcf 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1,16 +1,18 @@ import { createVSCodeAPIs } from './api'; import { onCommand, onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge'; -import type { RuntimeAPIs } from '../../ui/src/lib/api/types'; +import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; import { buildVSCodeThemeFromPalette, readVSCodeThemePalette, type VSCodeThemeKind, type VSCodeThemePayload, -} from '../../ui/src/lib/theme/vscode/adapter'; +} from '@openchamber/ui/lib/theme/vscode/adapter'; type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected'; type PanelType = 'chat' | 'agentManager'; +declare const __OPENCHAMBER_WEBVIEW_BUILD_TIME__: string; + declare global { interface Window { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs; @@ -31,7 +33,15 @@ declare global { } console.log('[OpenChamber] VS Code webview starting...'); +console.log('[OpenChamber] VS Code webview build:', __OPENCHAMBER_WEBVIEW_BUILD_TIME__); console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__); +try { + if (window.localStorage.getItem('openchamber_stream_debug') === '1') { + console.log('[OpenChamber] Debug: openchamber_stream_debug=1'); + } +} catch { + // ignore +} window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs(); @@ -238,9 +248,19 @@ onThemeChange((payload) => { const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder; if (workspaceFolder) { - window.__OPENCHAMBER_HOME__ = workspaceFolder; + const normalizeWorkspacePath = (value: string) => { + const normalized = value.replace(/\\/g, '/'); + if (normalized === '/') { + return '/'; + } + return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; + }; + + const normalizedWorkspaceFolder = normalizeWorkspacePath(workspaceFolder); + window.__OPENCHAMBER_HOME__ = normalizedWorkspaceFolder; try { - window.localStorage.setItem('lastDirectory', workspaceFolder); + window.localStorage.setItem('lastDirectory', normalizedWorkspaceFolder); + window.localStorage.setItem('homeDirectory', normalizedWorkspaceFolder); } catch (error) { console.warn('Failed to persist workspace folder', error); } @@ -370,8 +390,29 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { 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; try { - const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body }); + 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' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -384,8 +425,29 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { 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; try { - const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body }); + 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' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -671,7 +733,7 @@ onCommand('addToContext', (payload) => { const { text } = payload as { text: string }; // Import the store dynamically to avoid circular dependencies - import('../../ui/src/stores/useSessionStore').then(({ useSessionStore }) => { + import('@/stores/useSessionStore').then(({ useSessionStore }) => { const store = useSessionStore.getState(); const currentText = store.pendingInputText || ''; // Append to existing text with double newline separator @@ -685,8 +747,8 @@ onCommand('createSessionWithPrompt', (payload) => { const { prompt } = payload as { prompt: string }; Promise.all([ - import('../../ui/src/stores/useSessionStore'), - import('../../ui/src/stores/useConfigStore'), + import('@/stores/useSessionStore'), + import('@/stores/useConfigStore'), ]).then(([{ useSessionStore }, { useConfigStore }]) => { const sessionStore = useSessionStore.getState(); const configStore = useConfigStore.getState(); @@ -719,7 +781,7 @@ onCommand('createSessionWithPrompt', (payload) => { // Listen for newSession command from extension title bar button onCommand('newSession', () => { - import('../../ui/src/stores/useSessionStore').then(({ useSessionStore }) => { + import('@/stores/useSessionStore').then(({ useSessionStore }) => { const store = useSessionStore.getState(); store.openNewSessionDraft(); }); @@ -734,7 +796,7 @@ onCommand('showSettings', () => { window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } })); }); -import('../../ui/src/main') +import('@/main') .then(async () => { await waitForUiMount(); uiMounted = true; diff --git a/packages/web/package.json b/packages/web/package.json index 1ec3f536..93fb9dc0 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.209", + "@opencode-ai/sdk": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 6873a4ea..f4e507ae 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -6,6 +6,7 @@ import fs from 'fs'; import http from 'http'; import { fileURLToPath } from 'url'; import os from 'os'; +import crypto from 'crypto'; import { createUiAuth } from './lib/ui-auth.js'; import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js'; @@ -315,6 +316,76 @@ const writeSettingsToDisk = async (settings) => { } }; +const resolveDirectoryCandidate = (value) => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const normalized = normalizeDirectoryPath(trimmed); + return path.resolve(normalized); +}; + +const validateDirectoryPath = async (candidate) => { + const resolved = resolveDirectoryCandidate(candidate); + if (!resolved) { + return { ok: false, error: 'Directory parameter is required' }; + } + try { + const stats = await fsPromises.stat(resolved); + if (!stats.isDirectory()) { + return { ok: false, error: 'Specified path is not a directory' }; + } + return { ok: true, directory: resolved }; + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return { ok: false, error: 'Directory not found' }; + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return { ok: false, error: 'Access to directory denied' }; + } + return { ok: false, error: 'Failed to validate directory' }; + } +}; + +const resolveProjectDirectory = async (req) => { + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requested = headerDirectory || queryDirectory || null; + + if (requested) { + const validated = await validateDirectoryPath(requested); + if (!validated.ok) { + return { directory: null, error: validated.error }; + } + return { directory: validated.directory, error: null }; + } + + const settings = await readSettingsFromDiskMigrated(); + const projects = sanitizeProjects(settings.projects) || []; + if (projects.length === 0) { + return { directory: null, error: 'Directory parameter or active project is required' }; + } + + const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; + const active = projects.find((project) => project.id === activeId) || projects[0]; + if (!active || !active.path) { + return { directory: null, error: 'Directory parameter or active project is required' }; + } + + const validated = await validateDirectoryPath(active.path); + if (!validated.ok) { + return { directory: null, error: validated.error }; + } + + return { directory: validated.directory, error: null }; +}; + const sanitizeTypographySizesPartial = (input) => { if (!input || typeof input !== 'object') { return undefined; @@ -384,6 +455,47 @@ const sanitizeSkillCatalogs = (input) => { return result; }; +const sanitizeProjects = (input) => { + if (!Array.isArray(input)) { + return undefined; + } + + const result = []; + const seenIds = new Set(); + const seenPaths = new Set(); + + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + + const candidate = entry; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; + const normalizedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null; + const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt) + ? Number(candidate.lastOpenedAt) + : null; + + if (!id || !normalizedPath) continue; + if (seenIds.has(id)) continue; + if (seenPaths.has(normalizedPath)) continue; + + seenIds.add(id); + seenPaths.add(normalizedPath); + + result.push({ + id, + path: normalizedPath, + ...(label ? { label } : {}), + ...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}), + ...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}), + }); + } + + return result; +}; + const sanitizeSettingsUpdate = (payload) => { if (!payload || typeof payload !== 'object') { return {}; @@ -413,6 +525,15 @@ const sanitizeSettingsUpdate = (payload) => { if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { result.homeDirectory = candidate.homeDirectory; } + if (Array.isArray(candidate.projects)) { + const projects = sanitizeProjects(candidate.projects); + if (projects) { + result.projects = projects; + } + } + if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) { + result.activeProjectId = candidate.activeProjectId; + } if (Array.isArray(candidate.approvedDirectories)) { result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories); @@ -481,6 +602,16 @@ const mergePersistedSettings = (current, changes) => { if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) { additionalApproved.push(changes.homeDirectory); } + const projectEntries = Array.isArray(changes.projects) + ? changes.projects + : Array.isArray(current.projects) + ? current.projects + : []; + projectEntries.forEach((project) => { + if (project && typeof project.path === 'string' && project.path.length > 0) { + additionalApproved.push(project.path); + } + }); const approvedSource = [...baseApproved, ...additionalApproved]; const baseBookmarks = Array.isArray(changes.securityScopedBookmarks) @@ -535,10 +666,124 @@ const formatSettingsResponse = (settings) => { }; }; +const validateProjectEntries = async (projects) => { + if (!Array.isArray(projects)) { + return []; + } + + const results = []; + for (const project of projects) { + if (!project || typeof project.path !== 'string' || project.path.length === 0) { + continue; + } + try { + const stats = await fsPromises.stat(project.path); + if (!stats.isDirectory()) { + continue; + } + results.push(project); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + continue; + } + continue; + } + } + + return results; +}; + +const migrateSettingsFromLegacyLastDirectory = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + const now = Date.now(); + + const sanitizedProjects = sanitizeProjects(settings.projects) || []; + let nextProjects = sanitizedProjects; + let nextActiveProjectId = + typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined; + + let changed = false; + + if (nextProjects.length === 0) { + const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : ''; + const candidate = legacy ? resolveDirectoryCandidate(legacy) : null; + + if (candidate) { + try { + const stats = await fsPromises.stat(candidate); + if (stats.isDirectory()) { + const id = crypto.randomUUID(); + nextProjects = [ + { + id, + path: candidate, + addedAt: now, + lastOpenedAt: now, + }, + ]; + nextActiveProjectId = id; + changed = true; + } + } catch { + // ignore invalid lastDirectory + } + } + } + + if (nextProjects.length > 0) { + const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null; + if (!active) { + nextActiveProjectId = nextProjects[0].id; + changed = true; + } + } else if (nextActiveProjectId) { + nextActiveProjectId = undefined; + changed = true; + } + + if (!changed) { + return { settings, changed: false }; + } + + const merged = mergePersistedSettings(settings, { + ...settings, + projects: nextProjects, + ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }), + }); + + return { settings: merged, changed: true }; +}; + +const readSettingsFromDiskMigrated = async () => { + const current = await readSettingsFromDisk(); + const { settings, changed } = await migrateSettingsFromLegacyLastDirectory(current); + if (changed) { + await writeSettingsToDisk(settings); + } + return settings; +}; + const persistSettings = async (changes) => { const current = await readSettingsFromDisk(); const sanitized = sanitizeSettingsUpdate(changes); - const next = mergePersistedSettings(current, sanitized); + let next = mergePersistedSettings(current, sanitized); + + if (Array.isArray(next.projects)) { + const validated = await validateProjectEntries(next.projects); + next = { ...next, projects: validated }; + } + + if (Array.isArray(next.projects) && next.projects.length > 0) { + const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : ''; + const active = next.projects.find((project) => project.id === activeId) || null; + if (!active) { + next = { ...next, activeProjectId: next.projects[0].id }; + } + } else if (next.activeProjectId) { + next = { ...next, activeProjectId: undefined }; + } + await writeSettingsToDisk(next); return formatSettingsResponse(next); }; @@ -551,7 +796,7 @@ const getHmrState = () => { globalThis[HMR_STATE_KEY] = { openCodeProcess: null, openCodePort: null, - openCodeWorkingDirectory: process.cwd(), + openCodeWorkingDirectory: os.homedir(), isShuttingDown: false, signalsAttached: false, }; @@ -2152,6 +2397,10 @@ async function main(options = {}) { res.flushHeaders(); } + const heartbeatInterval = setInterval(() => { + writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() }); + }, 30000); + const decoder = new TextDecoder(); const reader = upstream.body.getReader(); let buffer = ''; @@ -2194,6 +2443,7 @@ async function main(options = {}) { console.warn('SSE proxy stream error:', error); } } finally { + clearInterval(heartbeatInterval); cleanup(); try { res.end(); @@ -2220,11 +2470,13 @@ async function main(options = {}) { return res.status(503).json({ error: 'OpenCode service unavailable' }); } + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; const directoryParam = Array.isArray(req.query.directory) ? req.query.directory[0] : req.query.directory; - if (typeof directoryParam === 'string' && directoryParam.trim().length > 0) { - targetUrl.searchParams.set('directory', directoryParam.trim()); + const resolvedDirectory = headerDirectory || directoryParam || null; + if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) { + targetUrl.searchParams.set('directory', resolvedDirectory.trim()); } const headers = { @@ -2271,6 +2523,10 @@ async function main(options = {}) { res.flushHeaders(); } + const heartbeatInterval = setInterval(() => { + writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() }); + }, 30000); + const decoder = new TextDecoder(); const reader = upstream.body.getReader(); let buffer = ''; @@ -2313,6 +2569,7 @@ async function main(options = {}) { console.warn('SSE proxy stream error:', error); } } finally { + clearInterval(heartbeatInterval); cleanup(); try { res.end(); @@ -2324,7 +2581,7 @@ async function main(options = {}) { app.get('/api/config/settings', async (_req, res) => { try { - const settings = await readSettingsFromDisk(); + const settings = await readSettingsFromDiskMigrated(); res.json(formatSettingsResponse(settings)); } catch (error) { console.error('Failed to load settings:', error); @@ -2345,6 +2602,7 @@ async function main(options = {}) { const { getAgentSources, getAgentScope, + getAgentConfig, createAgent, updateAgent, deleteAgent, @@ -2357,16 +2615,23 @@ async function main(options = {}) { COMMAND_SCOPE } = await import('./lib/opencode-config.js'); - app.get('/api/config/agents/:name', (req, res) => { + app.get('/api/config/agents/:name', async (req, res) => { try { const agentName = req.params.name; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; - const sources = getAgentSources(agentName, workingDirectory); + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const sources = getAgentSources(agentName, directory); + + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); res.json({ name: agentName, sources: sources, - scope: sources.md.scope, + scope, isBuiltIn: !sources.md.exists && !sources.json.exists }); } catch (error) { @@ -2375,17 +2640,36 @@ async function main(options = {}) { } }); + app.get('/api/config/agents/:name/config', async (req, res) => { + try { + const agentName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + const configInfo = getAgentConfig(agentName, directory); + res.json(configInfo); + } catch (error) { + console.error('Failed to get agent config:', error); + res.status(500).json({ error: 'Failed to get agent configuration' }); + } + }); + app.post('/api/config/agents/:name', async (req, res) => { try { const agentName = req.params.name; const { scope, ...config } = req.body; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } console.log('[Server] Creating agent:', agentName); console.log('[Server] Config received:', JSON.stringify(config, null, 2)); - console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory); + console.log('[Server] Scope:', scope, 'Working directory:', directory); - createAgent(agentName, config, workingDirectory, scope); + createAgent(agentName, config, directory, scope); await refreshOpenCodeAfterConfigChange('agent creation', { agentName }); @@ -2406,13 +2690,16 @@ async function main(options = {}) { try { const agentName = req.params.name; const updates = req.body; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } console.log(`[Server] Updating agent: ${agentName}`); console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); - console.log('[Server] Working directory:', workingDirectory); + console.log('[Server] Working directory:', directory); - updateAgent(agentName, updates, workingDirectory); + updateAgent(agentName, updates, directory); await refreshOpenCodeAfterConfigChange('agent update'); console.log(`[Server] Agent ${agentName} updated successfully`); @@ -2433,9 +2720,12 @@ async function main(options = {}) { app.delete('/api/config/agents/:name', async (req, res) => { try { const agentName = req.params.name; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } - deleteAgent(agentName, workingDirectory); + deleteAgent(agentName, directory); await refreshOpenCodeAfterConfigChange('agent deletion'); res.json({ @@ -2450,16 +2740,23 @@ async function main(options = {}) { } }); - app.get('/api/config/commands/:name', (req, res) => { + app.get('/api/config/commands/:name', async (req, res) => { try { const commandName = req.params.name; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; - const sources = getCommandSources(commandName, workingDirectory); + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const sources = getCommandSources(commandName, directory); + + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); res.json({ name: commandName, sources: sources, - scope: sources.md.scope, + scope, isBuiltIn: !sources.md.exists && !sources.json.exists }); } catch (error) { @@ -2472,13 +2769,16 @@ async function main(options = {}) { try { const commandName = req.params.name; const { scope, ...config } = req.body; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } console.log('[Server] Creating command:', commandName); console.log('[Server] Config received:', JSON.stringify(config, null, 2)); - console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory); + console.log('[Server] Scope:', scope, 'Working directory:', directory); - createCommand(commandName, config, workingDirectory, scope); + createCommand(commandName, config, directory, scope); await refreshOpenCodeAfterConfigChange('command creation', { commandName }); @@ -2499,13 +2799,16 @@ async function main(options = {}) { try { const commandName = req.params.name; const updates = req.body; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } console.log(`[Server] Updating command: ${commandName}`); console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); - console.log('[Server] Working directory:', workingDirectory); + console.log('[Server] Working directory:', directory); - updateCommand(commandName, updates, workingDirectory); + updateCommand(commandName, updates, directory); await refreshOpenCodeAfterConfigChange('command update'); console.log(`[Server] Command ${commandName} updated successfully`); @@ -2526,9 +2829,12 @@ async function main(options = {}) { app.delete('/api/config/commands/:name', async (req, res) => { try { const commandName = req.params.name; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } - deleteCommand(commandName, workingDirectory); + deleteCommand(commandName, directory); await refreshOpenCodeAfterConfigChange('command deletion'); res.json({ @@ -2559,14 +2865,17 @@ async function main(options = {}) { } = await import('./lib/opencode-config.js'); // List all discovered skills - app.get('/api/config/skills', (req, res) => { + app.get('/api/config/skills', async (req, res) => { try { - const workingDirectory = req.query.directory || openCodeWorkingDirectory; - const skills = discoverSkills(workingDirectory); + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const skills = discoverSkills(directory); // Enrich with full sources info const enrichedSkills = skills.map(skill => { - const sources = getSkillSources(skill.name, workingDirectory); + const sources = getSkillSources(skill.name, directory); return { ...skill, sources @@ -2616,7 +2925,10 @@ async function main(options = {}) { app.get('/api/config/skills/catalog', async (req, res) => { try { - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; const curatedSources = getCuratedSkillsSources(); @@ -2634,7 +2946,7 @@ async function main(options = {}) { const sources = [...curatedSources, ...customSources]; - const discovered = discoverSkills(workingDirectory); + const discovered = discoverSkills(directory); const installedByName = new Map(discovered.map((s) => [s.name, s])); const itemsBySource = {}; @@ -2738,12 +3050,16 @@ async function main(options = {}) { conflictDecisions, } = req.body || {}; - const workingDirectory = req.query.directory; - if (scope === 'project' && !workingDirectory) { - return res.status(400).json({ - ok: false, - error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' }, - }); + let workingDirectory = null; + if (scope === 'project') { + const resolved = await resolveProjectDirectory(req); + if (!resolved.directory) { + return res.status(400).json({ + ok: false, + error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' }, + }); + } + workingDirectory = resolved.directory; } const identity = resolveGitIdentity(gitIdentityId); @@ -2785,11 +3101,14 @@ async function main(options = {}) { }); // Get single skill sources - app.get('/api/config/skills/:name', (req, res) => { + app.get('/api/config/skills/:name', async (req, res) => { try { const skillName = req.params.name; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; - const sources = getSkillSources(skillName, workingDirectory); + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const sources = getSkillSources(skillName, directory); res.json({ name: skillName, @@ -2805,13 +3124,16 @@ async function main(options = {}) { }); // Get skill supporting file content - app.get('/api/config/skills/:name/files/*filePath', (req, res) => { + app.get('/api/config/skills/:name/files/*filePath', async (req, res) => { try { const skillName = req.params.name; const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } - const sources = getSkillSources(skillName, workingDirectory); + const sources = getSkillSources(skillName, directory); if (!sources.md.exists || !sources.md.dir) { return res.status(404).json({ error: 'Skill not found' }); } @@ -2833,12 +3155,15 @@ async function main(options = {}) { try { const skillName = req.params.name; const { scope, ...config } = req.body; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } console.log('[Server] Creating skill:', skillName); - console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory); + console.log('[Server] Scope:', scope, 'Working directory:', directory); - createSkill(skillName, config, workingDirectory, scope); + createSkill(skillName, config, directory, scope); // Skills are just files - OpenCode loads them on-demand, no restart needed res.json({ @@ -2857,12 +3182,15 @@ async function main(options = {}) { try { const skillName = req.params.name; const updates = req.body; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } console.log(`[Server] Updating skill: ${skillName}`); - console.log('[Server] Working directory:', workingDirectory); + console.log('[Server] Working directory:', directory); - updateSkill(skillName, updates, workingDirectory); + updateSkill(skillName, updates, directory); // Skills are just files - OpenCode loads them on-demand, no restart needed res.json({ @@ -2882,9 +3210,12 @@ async function main(options = {}) { const skillName = req.params.name; const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path const { content } = req.body; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } - const sources = getSkillSources(skillName, workingDirectory); + const sources = getSkillSources(skillName, directory); if (!sources.md.exists || !sources.md.dir) { return res.status(404).json({ error: 'Skill not found' }); } @@ -2906,9 +3237,12 @@ async function main(options = {}) { try { const skillName = req.params.name; const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } - const sources = getSkillSources(skillName, workingDirectory); + const sources = getSkillSources(skillName, directory); if (!sources.md.exists || !sources.md.dir) { return res.status(404).json({ error: 'Skill not found' }); } @@ -2929,9 +3263,12 @@ async function main(options = {}) { app.delete('/api/config/skills/:name', async (req, res) => { try { const skillName = req.params.name; - const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } - deleteSkill(skillName, workingDirectory); + deleteSkill(skillName, directory); // Skills are just files - OpenCode loads them on-demand, no restart needed res.json({ @@ -3677,40 +4014,41 @@ async function main(options = {}) { return res.status(400).json({ error: 'Path is required' }); } - const resolvedPath = path.resolve(normalizeDirectoryPath(requestedPath)); - let stats; - try { - stats = await fsPromises.stat(resolvedPath); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && 'code' in err) { - if (err.code === 'ENOENT') { - return res.status(404).json({ error: 'Directory not found' }); - } - if (err.code === 'EACCES') { - return res.status(403).json({ error: 'Access to directory denied' }); - } - } - throw error; + const validated = await validateDirectoryPath(requestedPath); + if (!validated.ok) { + return res.status(400).json({ error: validated.error }); } - if (!stats.isDirectory()) { - return res.status(400).json({ error: 'Specified path is not a directory' }); - } + const resolvedPath = validated.directory; + const currentSettings = await readSettingsFromDisk(); + const existingProjects = sanitizeProjects(currentSettings.projects) || []; + const existing = existingProjects.find((project) => project.path === resolvedPath) || null; - if (openCodeWorkingDirectory === resolvedPath && openCodeProcess && openCodeProcess.exitCode === null) { - return res.json({ success: true, restarted: false, path: resolvedPath }); - } + const nextProjects = existing + ? existingProjects + : [ + ...existingProjects, + { + id: crypto.randomUUID(), + path: resolvedPath, + addedAt: Date.now(), + lastOpenedAt: Date.now(), + }, + ]; - openCodeWorkingDirectory = resolvedPath; - syncToHmrState(); + const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id; - await refreshOpenCodeAfterConfigChange('directory change'); + const updated = await persistSettings({ + projects: nextProjects, + activeProjectId, + lastDirectory: resolvedPath, + }); res.json({ success: true, - restarted: true, - path: resolvedPath + restarted: false, + path: resolvedPath, + settings: updated, }); } catch (error) { console.error('Failed to update OpenCode working directory:', error); diff --git a/packages/web/server/lib/opencode-config.js b/packages/web/server/lib/opencode-config.js index cb7b808f..f75642bc 100644 --- a/packages/web/server/lib/opencode-config.js +++ b/packages/web/server/lib/opencode-config.js @@ -101,22 +101,72 @@ function getAgentWritePath(agentName, workingDirectory, requestedScope) { if (existing.path) { return existing; } - + // For new agents or built-in overrides: use requested scope or default to user const scope = requestedScope || AGENT_SCOPE.USER; if (scope === AGENT_SCOPE.PROJECT && workingDirectory) { - return { - scope: AGENT_SCOPE.PROJECT, - path: getProjectAgentPath(workingDirectory, agentName) + return { + scope: AGENT_SCOPE.PROJECT, + path: getProjectAgentPath(workingDirectory, agentName) }; } - - return { - scope: AGENT_SCOPE.USER, - path: getUserAgentPath(agentName) + + return { + scope: AGENT_SCOPE.USER, + path: getUserAgentPath(agentName) }; } +/** + * Detect where an agent's permission field is currently defined + * Priority: project .md > user .md > project JSON > user JSON + * Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null } + */ +function getAgentPermissionSource(agentName, workingDirectory) { + // Check project-level .md first + if (workingDirectory) { + const projectMdPath = getProjectAgentPath(workingDirectory, agentName); + if (fs.existsSync(projectMdPath)) { + const { frontmatter } = parseMdFile(projectMdPath); + if (frontmatter.permission !== undefined) { + return { source: 'md', scope: AGENT_SCOPE.PROJECT, path: projectMdPath }; + } + } + } + + // Check user-level .md + const userMdPath = getUserAgentPath(agentName); + if (fs.existsSync(userMdPath)) { + const { frontmatter } = parseMdFile(userMdPath); + if (frontmatter.permission !== undefined) { + return { source: 'md', scope: AGENT_SCOPE.USER, path: userMdPath }; + } + } + + // Check JSON layers (project > user) + const layers = readConfigLayers(workingDirectory); + + // Project opencode.json + const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission; + if (projectJsonPermission !== undefined && layers.paths.projectPath) { + return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath }; + } + + // User opencode.json + const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission; + if (userJsonPermission !== undefined) { + return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath }; + } + + // Custom config (env var) + const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission; + if (customJsonPermission !== undefined && layers.paths.customPath) { + return { source: 'json', scope: 'custom', path: layers.paths.customPath }; + } + + return { source: null, scope: null, path: null }; +} + // ============== COMMAND SCOPE HELPERS ============== /** @@ -412,9 +462,125 @@ function writePromptFile(filePath, content) { console.log(`Updated prompt file: ${filePath}`); } +/** + * Get all possible project config paths in priority order + * Priority: root > .opencode/, json > jsonc + */ +function getProjectConfigCandidates(workingDirectory) { + if (!workingDirectory) return []; + return [ + path.join(workingDirectory, 'opencode.json'), + path.join(workingDirectory, 'opencode.jsonc'), + path.join(workingDirectory, '.opencode', 'opencode.json'), + path.join(workingDirectory, '.opencode', 'opencode.jsonc'), + ]; +} + +/** + * Find existing project config file or return default path for new config + */ function getProjectConfigPath(workingDirectory) { if (!workingDirectory) return null; - return path.join(workingDirectory, 'opencode.json'); + + const candidates = getProjectConfigCandidates(workingDirectory); + + // Return first existing config file + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + // Default to root opencode.json for new configs + return candidates[0]; +} + +/** + * Merge new permission config with existing non-wildcard patterns + * Non-wildcard patterns (patterns other than "*") are preserved from existing config + * @param {object|string|null} newPermission - New permission config from UI (wildcards only) + * @param {object} permissionSource - Result from getAgentPermissionSource + * @param {string} agentName - Agent name + * @param {string|null} workingDirectory - Working directory + * @returns {object|string|null} Merged permission config + */ +function mergePermissionWithNonWildcards(newPermission, permissionSource, agentName, workingDirectory) { + // If no existing permission, return new permission as-is + if (!permissionSource.source || !permissionSource.path) { + return newPermission; + } + + // Get existing permission config + let existingPermission = null; + if (permissionSource.source === 'md') { + const { frontmatter } = parseMdFile(permissionSource.path); + existingPermission = frontmatter.permission; + } else if (permissionSource.source === 'json') { + const config = readConfigFile(permissionSource.path); + existingPermission = config?.agent?.[agentName]?.permission; + } + + // If no existing permission or it's a simple string, return new permission as-is + if (!existingPermission || typeof existingPermission === 'string') { + return newPermission; + } + + // If new permission is null/undefined, return null to clear it + if (newPermission == null) { + return null; + } + + // If new permission is a simple string (e.g., "allow"), return it as-is + if (typeof newPermission === 'string') { + return newPermission; + } + + // Extract non-wildcard patterns from existing permission + const nonWildcardPatterns = {}; + for (const [permKey, permValue] of Object.entries(existingPermission)) { + if (permKey === '*') continue; // Skip global default + + if (typeof permValue === 'object' && permValue !== null && !Array.isArray(permValue)) { + // Permission has pattern-based config (e.g., { "npm *": "allow", "*": "ask" }) + const nonWildcards = {}; + for (const [pattern, action] of Object.entries(permValue)) { + if (pattern !== '*') { + nonWildcards[pattern] = action; + } + } + if (Object.keys(nonWildcards).length > 0) { + nonWildcardPatterns[permKey] = nonWildcards; + } + } + // Simple string values (e.g., "allow") don't have patterns, skip them + } + + // If no non-wildcard patterns to preserve, return new permission as-is + if (Object.keys(nonWildcardPatterns).length === 0) { + return newPermission; + } + + // Merge non-wildcards into new permission + const merged = { ...newPermission }; + for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) { + const newValue = merged[permKey]; + if (typeof newValue === 'string') { + // Convert string to object with wildcard + preserved patterns + merged[permKey] = { '*': newValue, ...patterns }; + } else if (typeof newValue === 'object' && newValue !== null) { + // Merge patterns, new wildcards take precedence + merged[permKey] = { ...patterns, ...newValue }; + } else { + // Permission not in new config - preserve existing patterns with their wildcard if it existed + const existingValue = existingPermission[permKey]; + if (typeof existingValue === 'object' && existingValue !== null) { + const wildcard = existingValue['*']; + merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns; + } + } + } + + return merged; } function getConfigPaths(workingDirectory) { @@ -539,22 +705,23 @@ function getJsonWriteTarget(layers, preferredScope) { } function parseMdFile(filePath) { - try { - const content = fs.readFileSync(filePath, 'utf8'); - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + const content = fs.readFileSync(filePath, 'utf8'); + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); - if (!match) { - return { frontmatter: {}, body: content.trim() }; - } - - const frontmatter = yaml.parse(match[1]) || {}; - const body = match[2].trim(); - - return { frontmatter, body }; - } catch (error) { - console.error(`Failed to parse markdown file ${filePath}:`, error); - throw new Error('Failed to parse agent markdown file'); + if (!match) { + return { frontmatter: {}, body: content.trim() }; } + + let frontmatter = {}; + try { + frontmatter = yaml.parse(match[1]) || {}; + } catch (error) { + console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error); + frontmatter = {}; + } + + const body = match[2].trim(); + return { frontmatter, body }; } function writeMdFile(filePath, frontmatter, body) { @@ -606,12 +773,6 @@ function getAgentSources(agentName, workingDirectory) { scope: jsonSource.exists ? jsonScope : null, fields: [] }, - json: { - exists: jsonSource.exists, - path: jsonPath, - scope: jsonSource.exists ? jsonScope : null, - fields: [] - }, // Additional info about both levels projectMd: { exists: projectExists, @@ -638,6 +799,48 @@ function getAgentSources(agentName, workingDirectory) { return sources; } +function getAgentConfig(agentName, workingDirectory) { + // Prefer markdown agents (project > user) + const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null; + const projectExists = projectPath && fs.existsSync(projectPath); + + const userPath = getUserAgentPath(agentName); + const userExists = fs.existsSync(userPath); + + if (projectExists || userExists) { + const mdPath = projectExists ? projectPath : userPath; + const { frontmatter, body } = parseMdFile(mdPath); + + return { + source: 'md', + scope: projectExists ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER, + config: { + ...frontmatter, + ...(typeof body === 'string' && body.length > 0 ? { prompt: body } : {}), + }, + }; + } + + // Then fall back to opencode.json (highest-precedence entry) + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'agent', agentName); + + if (jsonSource.exists && jsonSource.section) { + const scope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER; + return { + source: 'json', + scope, + config: { ...jsonSource.section }, + }; + } + + return { + source: 'none', + scope: null, + config: {}, + }; +} + function createAgent(agentName, config, workingDirectory, scope) { ensureDirs(); @@ -693,7 +896,7 @@ function updateAgent(agentName, updates, workingDirectory) { const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0; const jsonTarget = jsonSource.exists ? { config: jsonSource.config, path: jsonSource.path } - : getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER); + : getJsonWriteTarget(layers, AGENT_SCOPE.USER); let config = jsonTarget.config || {}; // Determine if we should create a new md file: @@ -742,7 +945,7 @@ function updateAgent(agentName, updates, workingDirectory) { jsonModified = true; continue; } - + // For JSON-only agents, store prompt inline in JSON if (!config.agent) config.agent = {}; if (!config.agent[agentName]) config.agent[agentName] = {}; @@ -751,9 +954,79 @@ function updateAgent(agentName, updates, workingDirectory) { continue; } + // Special handling for permission field - uses location detection and preserves non-wildcards + if (field === 'permission') { + const permissionSource = getAgentPermissionSource(agentName, workingDirectory); + const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName, workingDirectory); + + if (permissionSource.source === 'md') { + // Write to existing .md file + const existingMdData = parseMdFile(permissionSource.path); + existingMdData.frontmatter.permission = newPermission; + writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body); + console.log(`Updated permission in .md file: ${permissionSource.path}`); + } else if (permissionSource.source === 'json') { + // Write to existing JSON location + const existingConfig = readConfigFile(permissionSource.path); + if (!existingConfig.agent) existingConfig.agent = {}; + if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {}; + existingConfig.agent[agentName].permission = newPermission; + writeConfig(existingConfig, permissionSource.path); + console.log(`Updated permission in JSON: ${permissionSource.path}`); + } else { + // Permission not defined anywhere - use agent's source location + if ((mdExists || creatingNewMd) && mdData) { + mdData.frontmatter.permission = newPermission; + mdModified = true; + } else if (hasJsonFields) { + // Agent exists in JSON - add permission there + if (!config.agent) config.agent = {}; + if (!config.agent[agentName]) config.agent[agentName] = {}; + config.agent[agentName].permission = newPermission; + jsonModified = true; + } else { + // Built-in agent with no config - write to project JSON if available, else user JSON + const writeTarget = workingDirectory + ? { config: layers.projectConfig || {}, path: layers.paths.projectPath || layers.paths.userPath } + : { config: layers.userConfig || {}, path: layers.paths.userPath }; + if (!writeTarget.config.agent) writeTarget.config.agent = {}; + if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {}; + writeTarget.config.agent[agentName].permission = newPermission; + writeConfig(writeTarget.config, writeTarget.path); + console.log(`Created permission in JSON: ${writeTarget.path}`); + } + } + continue; + } + const inMd = mdData?.frontmatter?.[field] !== undefined; const inJson = jsonSection?.[field] !== undefined; + if (value === null) { + // Treat null as a request to remove the field. + if (mdData && inMd) { + delete mdData.frontmatter[field]; + mdModified = true; + } + + if (inJson) { + if (config.agent?.[agentName]) { + delete config.agent[agentName][field]; + + if (Object.keys(config.agent[agentName]).length === 0) { + delete config.agent[agentName]; + } + if (Object.keys(config.agent).length === 0) { + delete config.agent; + } + + jsonModified = true; + } + } + + continue; + } + // JSON takes precedence over md, so update JSON first if field exists there if (inJson) { if (!config.agent) config.agent = {}; @@ -852,6 +1125,7 @@ function getCommandSources(commandName, workingDirectory) { const jsonSource = getJsonEntrySource(layers, 'command', commandName); const jsonSection = jsonSource.section; const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath; + const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER; const sources = { md: { @@ -863,6 +1137,7 @@ function getCommandSources(commandName, workingDirectory) { json: { exists: jsonSource.exists, path: jsonPath, + scope: jsonSource.exists ? jsonScope : null, fields: [] }, // Additional info about both levels @@ -1373,6 +1648,8 @@ function deleteSkill(skillName, workingDirectory) { export { getAgentSources, getAgentScope, + getAgentPermissionSource, + getAgentConfig, createAgent, updateAgent, deleteAgent,