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
This commit is contained in:
Bohdan Triapitsyn
2026-01-06 21:31:04 +02:00
committed by GitHub
parent 8aa379e313
commit 18c5b4c7b5
84 changed files with 8399 additions and 2854 deletions
+41
View File
@@ -175,6 +175,47 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 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>$key</key>"; then
echo "Error: required entitlement missing: $key"
exit 1
fi
done
- name: Prepare release artifacts - name: Prepare release artifacts
run: | run: |
mkdir -p artifacts mkdir -p artifacts
+7 -2
View File
@@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- Multi Run / Agent Manager: Select the Agent you want to run for the Worktree sessions - Multi Run / Agent Manager: Select the Agent you want to run for the Worktree sessions (thanks to @wienans).
- Agent Manager now submits the promt, if valid similar to the Chat interfaces - 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 ## [1.4.3] - 2026-01-04
+20 -9
View File
@@ -11,7 +11,7 @@
"@heroui/system": "^2.4.23", "@heroui/system": "^2.4.23",
"@heroui/theme": "^2.4.23", "@heroui/theme": "^2.4.23",
"@ibm/plex": "^6.4.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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -74,7 +74,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@openchamber/desktop", "name": "@openchamber/desktop",
"version": "1.4.2", "version": "1.4.3",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
@@ -97,12 +97,15 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.4.2", "version": "1.4.3",
"dependencies": { "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-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.0.209", "@opencode-ai/sdk": "^1.1.1",
"@pierre/diffs": "^1.0.0", "@pierre/diffs": "^1.0.0",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -164,10 +167,10 @@
}, },
"packages/vscode": { "packages/vscode": {
"name": "openchamber", "name": "openchamber",
"version": "1.4.2", "version": "1.4.3",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.0.209", "@opencode-ai/sdk": "^1.1.1",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
@@ -185,7 +188,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@openchamber/web", "name": "@openchamber/web",
"version": "1.4.2", "version": "1.4.3",
"bin": { "bin": {
"openchamber": "./bin/cli.js", "openchamber": "./bin/cli.js",
}, },
@@ -193,7 +196,7 @@
"@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@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=="], "@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/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=="], "@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"], "@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=="], "@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=="],
+1 -1
View File
@@ -63,7 +63,7 @@
"@heroui/system": "^2.4.23", "@heroui/system": "^2.4.23",
"@heroui/theme": "^2.4.23", "@heroui/theme": "^2.4.23",
"@ibm/plex": "^6.4.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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+61
View File
@@ -2146,6 +2146,17 @@ dependencies = [
"thiserror 1.0.69", "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]] [[package]]
name = "jsonptr" name = "jsonptr"
version = "0.6.3" version = "0.6.3"
@@ -2856,6 +2867,7 @@ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"fastrand", "fastrand",
"futures-util", "futures-util",
"json5",
"log", "log",
"nix 0.28.0", "nix 0.28.0",
"objc", "objc",
@@ -2993,6 +3005,49 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "phf" name = "phf"
version = "0.8.0" version = "0.8.0"
@@ -5303,6 +5358,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]] [[package]]
name = "uds_windows" name = "uds_windows"
version = "1.1.0" version = "1.1.0"
+1
View File
@@ -37,6 +37,7 @@ reqwest = { version = "0.12.4", default-features = false, features = ["json", "s
serde = { version = "1.0.210", features = ["derive"] } serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.143" serde_json = "1.0.143"
serde_yaml = "0.9" serde_yaml = "0.9"
json5 = "0.4"
tauri = { version = "2.9.4", features = ["macos-private-api"] } tauri = { version = "2.9.4", features = ["macos-private-api"] }
tauri-plugin-dialog = "2.4.2" tauri-plugin-dialog = "2.4.2"
tauri-plugin-fs = "2.4.4" tauri-plugin-fs = "2.4.4"
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Intentionally NOT sandboxed. This app is distributed outside the Mac App Store.
Do not add com.apple.security.app-sandbox.
These entitlements are commonly required for WKWebView/WebKit JIT behavior
under hardened runtime.
-->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
@@ -1,4 +1,4 @@
use std::{collections::HashSet, time::Duration}; use std::{collections::HashSet, path::PathBuf, time::Duration};
use anyhow::Result; use anyhow::Result;
use futures_util::TryStreamExt; use futures_util::TryStreamExt;
@@ -11,6 +11,7 @@ use tauri_plugin_notification::NotificationExt;
use tokio::{io::AsyncBufReadExt, sync::Mutex}; use tokio::{io::AsyncBufReadExt, sync::Mutex};
use tokio_util::io::StreamReader; use tokio_util::io::StreamReader;
use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime; use crate::DesktopRuntime;
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -21,6 +22,14 @@ struct EventEnvelope {
properties: Value, properties: Value,
} }
#[derive(Deserialize)]
struct MultiplexedEventEnvelope {
#[serde(default)]
#[allow(dead_code)]
directory: Option<String>,
payload: EventEnvelope,
}
pub fn spawn_assistant_notifications( pub fn spawn_assistant_notifications(
app: AppHandle, app: AppHandle,
runtime: DesktopRuntime, runtime: DesktopRuntime,
@@ -71,41 +80,8 @@ async fn run_once(
}; };
let prefix = opencode.api_prefix(); let prefix = opencode.api_prefix();
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix); let base = format!("http://127.0.0.1:{port}{prefix}");
let response = connect_notifications_sse(runtime, client, &base).await?;
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 stream = response let stream = response
.bytes_stream() .bytes_stream()
@@ -142,7 +118,7 @@ async fn run_once(
let raw = data_lines.join("\n"); let raw = data_lines.join("\n");
data_lines.clear(); data_lines.clear();
match serde_json::from_str::<EventEnvelope>(&raw) { match parse_event_envelope(&raw) {
Ok(event) => handle_event(app, event, notified_messages).await, Ok(event) => handle_event(app, event, notified_messages).await,
Err(err) => { Err(err) => {
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}"); warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
@@ -159,6 +135,111 @@ async fn run_once(
Ok(()) Ok(())
} }
fn parse_event_envelope(raw: &str) -> Result<EventEnvelope> {
if let Ok(event) = serde_json::from_str::<EventEnvelope>(raw) {
return Ok(event);
}
let multiplexed = serde_json::from_str::<MultiplexedEventEnvelope>(raw)?;
Ok(multiplexed.payload)
}
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
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<reqwest::Response> {
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<reqwest::Response> {
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( async fn handle_event(
app: &AppHandle, app: &AppHandle,
event: EventEnvelope, event: EventEnvelope,
+103 -35
View File
@@ -136,8 +136,8 @@ pub async fn list_directory(
path: Option<String>, path: Option<String>,
state: tauri::State<'_, DesktopRuntime>, state: tauri::State<'_, DesktopRuntime>,
) -> Result<DirectoryListResult, String> { ) -> Result<DirectoryListResult, String> {
let workspace_root = resolve_workspace_root(state.settings()).await; let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(path, workspace_root.as_ref()) let resolved_path = resolve_sandboxed_path(path, &workspace_roots, default_root.as_ref())
.await .await
.map_err(|err| err.to_list_message())?; .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 // Re-check boundary after canonicalization to guard against traversal
if let Some(root) = &workspace_root { if !workspace_roots.is_empty()
if !resolved_path.starts_with(root) { && !workspace_roots
return Err(FsCommandError::OutsideWorkspace.to_list_message()); .iter()
} .any(|root| resolved_path.starts_with(root))
{
return Err(FsCommandError::OutsideWorkspace.to_list_message());
} }
let mut entries = Vec::new(); let mut entries = Vec::new();
@@ -223,8 +225,8 @@ pub async fn search_files(
max_results: Option<usize>, max_results: Option<usize>,
state: tauri::State<'_, DesktopRuntime>, state: tauri::State<'_, DesktopRuntime>,
) -> Result<SearchFilesResponse, String> { ) -> Result<SearchFilesResponse, String> {
let workspace_root = resolve_workspace_root(state.settings()).await; let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_root = resolve_sandboxed_path(directory, workspace_root.as_ref()) let resolved_root = resolve_sandboxed_path(directory, &workspace_roots, default_root.as_ref())
.await .await
.map_err(|err| err.to_search_message())?; .map_err(|err| err.to_search_message())?;
@@ -352,8 +354,8 @@ pub async fn create_directory(
return Err("Path is required".to_string()); return Err("Path is required".to_string());
} }
let workspace_root = resolve_workspace_root(state.settings()).await; let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_creatable_path(trimmed, workspace_root.as_ref()) let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref())
.await .await
.map_err(|err| err.to_create_message())?; .map_err(|err| err.to_create_message())?;
@@ -369,35 +371,40 @@ pub async fn create_directory(
async fn resolve_sandboxed_path( async fn resolve_sandboxed_path(
path: Option<String>, path: Option<String>,
workspace_root: Option<&PathBuf>, workspace_roots: &[PathBuf],
default_root: Option<&PathBuf>,
) -> Result<PathBuf, FsCommandError> { ) -> Result<PathBuf, FsCommandError> {
let candidate_input = path let candidate_input = path
.as_ref() .as_ref()
.map(|value| value.trim()) .map(|value| value.trim())
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
let candidate_path = match (candidate_input, workspace_root) { let fallback_root = default_root
(Some(value), _) => expand_tilde_path(value), .or_else(|| workspace_roots.first())
(None, Some(root)) => root.clone(), .cloned()
(None, None) => default_home_directory(), .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() { let resolved = if candidate_path.is_absolute() {
candidate_path candidate_path
} else if let Some(root) = workspace_root {
root.join(candidate_path)
} else { } else {
default_home_directory().join(candidate_path) fallback_root.join(candidate_path)
}; };
let canonicalized = fs::canonicalize(&resolved) let canonicalized = fs::canonicalize(&resolved)
.await .await
.map_err(FsCommandError::from)?; .map_err(FsCommandError::from)?;
if let Some(root) = workspace_root { if !workspace_roots.is_empty()
if !canonicalized.starts_with(root) { && !workspace_roots
return Err(FsCommandError::OutsideWorkspace); .iter()
} .any(|root| canonicalized.starts_with(root))
{
return Err(FsCommandError::OutsideWorkspace);
} }
Ok(canonicalized) Ok(canonicalized)
@@ -405,19 +412,23 @@ async fn resolve_sandboxed_path(
async fn resolve_creatable_path( async fn resolve_creatable_path(
path: &str, path: &str,
workspace_root: Option<&PathBuf>, workspace_roots: &[PathBuf],
default_root: Option<&PathBuf>,
) -> Result<PathBuf, FsCommandError> { ) -> Result<PathBuf, FsCommandError> {
let candidate = expand_tilde_path(path); let candidate = expand_tilde_path(path);
if candidate.as_os_str().is_empty() { if candidate.as_os_str().is_empty() {
return Err(FsCommandError::Other("Path is required".to_string())); 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() { let absolute = if candidate.is_absolute() {
candidate candidate
} else if let Some(root) = workspace_root {
root.join(candidate)
} else { } else {
default_home_directory().join(candidate) fallback_root.join(candidate)
}; };
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?; let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
@@ -426,22 +437,79 @@ async fn resolve_creatable_path(
.await .await
.map_err(FsCommandError::from)?; .map_err(FsCommandError::from)?;
if let Some(root) = workspace_root { if !workspace_roots.is_empty()
if !canonical_parent.starts_with(root) { && !workspace_roots
return Err(FsCommandError::OutsideWorkspace); .iter()
} .any(|root| canonical_parent.starts_with(root))
{
return Err(FsCommandError::OutsideWorkspace);
} }
Ok(absolute) Ok(absolute)
} }
async fn resolve_workspace_root(settings: &SettingsStore) -> Option<PathBuf> { async fn resolve_workspace_roots(settings: &SettingsStore) -> (Vec<PathBuf>, Option<PathBuf>) {
if let Ok(Some(last_dir)) = settings.last_directory().await { let mut roots: Vec<PathBuf> = Vec::new();
if let Ok(canonicalized) = fs::canonicalize(&last_dir).await { let mut default_root: Option<PathBuf> = None;
return Some(canonicalized);
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<PathBuf> = Vec::new();
for root in roots {
if !deduped.iter().any(|existing| existing == &root) {
deduped.push(root);
}
}
(deduped, default_root)
} }
fn default_home_directory() -> PathBuf { fn default_home_directory() -> PathBuf {
@@ -1,7 +1,10 @@
use chrono::Utc;
use log::{info, warn}; use log::{info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::AppHandle; use tauri::AppHandle;
use tauri::State; use tauri::State;
use uuid::Uuid;
use crate::path_utils::expand_tilde_path; use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime; use crate::DesktopRuntime;
@@ -17,6 +20,8 @@ pub struct DirectoryPermissionRequest {
pub struct DirectoryPermissionResult { pub struct DirectoryPermissionResult {
success: bool, success: bool,
path: Option<String>, path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
project_id: Option<String>,
error: Option<String>, error: Option<String>,
} }
@@ -27,9 +32,8 @@ pub struct StartAccessingResult {
error: Option<String>, error: Option<String>,
} }
/// Process directory selection from frontend /// Process directory selection from frontend.
/// Updates settings with lastDirectory /// Updates settings (projects, activeProjectId, lastDirectory).
/// OpenCode restart is triggered separately via /api/opencode/directory endpoint
#[tauri::command] #[tauri::command]
pub async fn process_directory_selection( pub async fn process_directory_selection(
path: String, path: String,
@@ -45,6 +49,7 @@ pub async fn process_directory_selection(
return Ok(DirectoryPermissionResult { return Ok(DirectoryPermissionResult {
success: false, success: false,
path: None, path: None,
project_id: None,
error: Some("Directory does not exist".to_string()), error: Some("Directory does not exist".to_string()),
}); });
} }
@@ -53,38 +58,94 @@ pub async fn process_directory_selection(
return Ok(DirectoryPermissionResult { return Ok(DirectoryPermissionResult {
success: false, success: false,
path: None, path: None,
project_id: None,
error: Some("Path is not a directory".to_string()), error: Some("Path is not a directory".to_string()),
}); });
} }
// Update settings with lastDirectory // Update settings with projects + activeProjectId + lastDirectory
let mut settings = state let now = Utc::now().timestamp_millis();
.settings() let normalized_path_for_update = normalized_path.clone();
.load()
.await
.map_err(|e| format!("Failed to load settings: {}", e))?;
if let Some(obj) = settings.as_object_mut() { let (_, project_id) = state
obj.insert(
"lastDirectory".to_string(),
serde_json::Value::String(normalized_path.clone()),
);
}
state
.settings() .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 .await
.map_err(|e| format!("Failed to save updated settings: {}", e))?; .map_err(|e| format!("Failed to save updated settings: {}", e))?;
info!( info!(
"[permissions] Updated settings with lastDirectory: {}", "[permissions] Updated settings with active project {}: {}",
normalized_path project_id, normalized_path
); );
Ok(DirectoryPermissionResult { Ok(DirectoryPermissionResult {
success: true, success: true,
path: Some(normalized_path), path: Some(normalized_path),
project_id: Some(project_id),
error: None, error: None,
}) })
} }
@@ -98,6 +159,7 @@ pub async fn pick_directory(
Ok(DirectoryPermissionResult { Ok(DirectoryPermissionResult {
success: false, success: false,
path: None, path: None,
project_id: None,
error: Some( error: Some(
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(), "Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
), ),
@@ -122,6 +184,7 @@ pub async fn request_directory_access(
return Ok(DirectoryPermissionResult { return Ok(DirectoryPermissionResult {
success: false, success: false,
path: None, path: None,
project_id: None,
error: Some("Directory does not exist".to_string()), error: Some("Directory does not exist".to_string()),
}); });
} }
@@ -130,6 +193,7 @@ pub async fn request_directory_access(
return Ok(DirectoryPermissionResult { return Ok(DirectoryPermissionResult {
success: false, success: false,
path: None, path: None,
project_id: None,
error: Some("Path is not a directory".to_string()), error: Some("Path is not a directory".to_string()),
}); });
} }
@@ -139,11 +203,13 @@ pub async fn request_directory_access(
Ok(_) => Ok(DirectoryPermissionResult { Ok(_) => Ok(DirectoryPermissionResult {
success: true, success: true,
path: Some(normalized_path), path: Some(normalized_path),
project_id: None,
error: None, error: None,
}), }),
Err(e) => Ok(DirectoryPermissionResult { Err(e) => Ok(DirectoryPermissionResult {
success: false, success: false,
path: None, path: None,
project_id: None,
error: Some(format!("Cannot access directory: {}", e)), error: Some(format!("Cannot access directory: {}", e)),
}), }),
} }
@@ -1,7 +1,9 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use chrono::Utc;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashSet; use std::collections::HashSet;
use tauri::State; use tauri::State;
use uuid::Uuid;
use crate::path_utils::expand_tilde_path; use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime; use crate::DesktopRuntime;
@@ -19,52 +21,47 @@ pub struct RestartResult {
restarted: bool, restarted: bool,
} }
/// Load settings from disk (matches Express handler behavior) /// Load settings from disk.
#[tauri::command] #[tauri::command]
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> { pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
let settings = state let (settings, _) = state
.settings() .settings()
.load() .update_with(|mut settings| {
migrate_legacy_project_settings(&mut settings);
normalize_project_selection(&mut settings);
(settings, ())
})
.await .await
.map_err(|e| format!("Failed to load settings: {}", e))?; .map_err(|e| format!("Failed to load settings: {}", e))?;
Ok(SettingsLoadResult { Ok(SettingsLoadResult {
settings, settings: format_settings_response(&settings),
source: "desktop".to_string(), source: "desktop".to_string(),
}) })
} }
/// Save settings to disk with merge logic matching Express implementation /// Save settings to disk with merge logic.
#[tauri::command] #[tauri::command]
pub async fn save_settings( pub async fn save_settings(
changes: Value, changes: Value,
state: State<'_, DesktopRuntime>, state: State<'_, DesktopRuntime>,
) -> Result<Value, String> { ) -> Result<Value, String> {
// 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); let sanitized_changes = sanitize_settings_update(&changes);
// Merge changes into current settings let (merged, _) = state
let merged = merge_persisted_settings(&current, &sanitized_changes);
// Save merged settings
state
.settings() .settings()
.save(merged.clone()) .update_with(|current| {
let mut merged = merge_persisted_settings(&current, &sanitized_changes);
normalize_project_selection(&mut merged);
(merged, ())
})
.await .await
.map_err(|e| format!("Failed to save settings: {}", e))?; .map_err(|e| format!("Failed to save settings: {}", e))?;
// Format response
Ok(format_settings_response(&merged)) Ok(format_settings_response(&merged))
} }
/// Restart OpenCode CLI (matches Express /api/config/reload) /// Restart the backend process (config reload).
#[tauri::command] #[tauri::command]
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> { pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
state state
@@ -76,6 +73,82 @@ pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<Restar
Ok(RestartResult { restarted: true }) Ok(RestartResult { restarted: true })
} }
fn sanitize_projects(value: &Value) -> Option<Value> {
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) /// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
fn sanitize_settings_update(payload: &Value) -> Value { fn sanitize_settings_update(payload: &Value) -> Value {
let mut result = json!({}); let mut result = json!({});
@@ -116,6 +189,14 @@ fn sanitize_settings_update(payload: &Value) -> Value {
result_obj.insert("homeDirectory".to_string(), json!(expanded)); 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 let Some(Value::String(s)) = obj.get("uiFont") {
if !s.is_empty() { if !s.is_empty() {
result_obj.insert("uiFont".to_string(), json!(s)); result_obj.insert("uiFont".to_string(), json!(s));
@@ -259,6 +340,135 @@ fn sanitize_settings_update(payload: &Value) -> Value {
result 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) /// Merge persisted settings (port of Express mergePersistedSettings)
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value { fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
let mut result = current.clone(); 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<String> = base_approved.into_iter().collect(); let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
for item in additional_approved { for item in additional_approved {
approved_set.insert(item); approved_set.insert(item);
+305 -121
View File
@@ -22,9 +22,9 @@ use anyhow::{anyhow, Result};
use assistant_notifications::spawn_assistant_notifications; use assistant_notifications::spawn_assistant_notifications;
use axum::{ use axum::{
body::{to_bytes, Body}, body::{to_bytes, Body},
extract::{OriginalUri, State}, extract::{Request, State},
http::{Method, Request, Response, StatusCode}, http::{Method, StatusCode},
response::IntoResponse, response::{IntoResponse, Response},
routing::{any, get, post}, routing::{any, get, post},
Json, Router, Json, Router,
}; };
@@ -39,6 +39,7 @@ use commands::git::{
update_git_identity, update_git_identity,
}; };
use commands::logs::fetch_desktop_logs; use commands::logs::fetch_desktop_logs;
use commands::notifications::desktop_notify; use commands::notifications::desktop_notify;
use commands::permissions::{ use commands::permissions::{
pick_directory, process_directory_selection, request_directory_access, pick_directory, process_directory_selection, request_directory_access,
@@ -157,10 +158,7 @@ pub(crate) struct DesktopRuntime {
impl DesktopRuntime { impl DesktopRuntime {
fn initialize_sync() -> Result<Self> { fn initialize_sync() -> Result<Self> {
let settings = Arc::new(SettingsStore::new()?); let settings = Arc::new(SettingsStore::new()?);
let initial_dir = tauri::async_runtime::block_on(settings.last_directory()) let opencode = Arc::new(OpenCodeManager::new_with_directory(None));
.ok()
.flatten();
let opencode = Arc::new(OpenCodeManager::new_with_directory(initial_dir.clone()));
let client = Client::builder().build()?; let client = Client::builder().build()?;
@@ -170,6 +168,7 @@ impl DesktopRuntime {
let server_state = ServerState { let server_state = ServerState {
client, client,
opencode: opencode.clone(), opencode: opencode.clone(),
settings: settings.clone(),
server_port, server_port,
directory_change_lock: Arc::new(Mutex::new(())), directory_change_lock: Arc::new(Mutex::new(())),
models_metadata_cache: Arc::new(Mutex::new(ModelsMetadataCache::default())), models_metadata_cache: Arc::new(Mutex::new(ModelsMetadataCache::default())),
@@ -217,6 +216,7 @@ impl DesktopRuntime {
struct ServerState { struct ServerState {
client: Client, client: Client,
opencode: Arc<OpenCodeManager>, opencode: Arc<OpenCodeManager>,
settings: Arc<SettingsStore>,
server_port: u16, server_port: u16,
directory_change_lock: Arc<Mutex<()>>, directory_change_lock: Arc<Mutex<()>>,
models_metadata_cache: Arc<Mutex<ModelsMetadataCache>>, models_metadata_cache: Arc<Mutex<ModelsMetadataCache>>,
@@ -420,21 +420,11 @@ fn build_macos_menu<R: tauri::Runtime>(
)?; )?;
// View menu items // View menu items
let open_git_tab = MenuItem::with_id( let open_git_tab =
app, MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Cmd+G"))?;
MENU_ITEM_OPEN_GIT_TAB_ID,
"Git",
true,
Some("Cmd+G"),
)?;
let open_diff_tab = MenuItem::with_id( let open_diff_tab =
app, MenuItem::with_id(app, MENU_ITEM_OPEN_DIFF_TAB_ID, "Diff", true, Some("Cmd+E"))?;
MENU_ITEM_OPEN_DIFF_TAB_ID,
"Diff",
true,
Some("Cmd+E"),
)?;
let open_terminal_tab = MenuItem::with_id( let open_terminal_tab = MenuItem::with_id(
app, app,
@@ -718,18 +708,8 @@ fn main() {
let app_handle = app.app_handle().clone(); let app_handle = app.app_handle().clone();
let runtime_clone = runtime.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 { tauri::async_runtime::spawn(async move {
// Only start opencode if we have a saved directory, otherwise frontend will prompt runtime_clone.start_opencode().await;
if has_initial_dir {
runtime_clone.start_opencode().await;
} else {
info!("[desktop] No saved directory - waiting for user to select one");
}
if let Err(e) = if let Err(e) =
restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await
@@ -1190,11 +1170,11 @@ struct DirectoryChangeResponse {
path: String, path: String,
} }
fn json_response<T: Serialize>(status: StatusCode, payload: T) -> Response<Body> { fn json_response<T: Serialize>(status: StatusCode, payload: T) -> Response {
(status, Json(payload)).into_response() (status, Json(payload)).into_response()
} }
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response<Body> { fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response {
json_response( json_response(
status, status,
ConfigErrorResponse { ConfigErrorResponse {
@@ -1203,10 +1183,8 @@ fn config_error_response(status: StatusCode, message: impl Into<String>) -> Resp
) )
} }
async fn parse_request_payload( async fn parse_request_payload(req: &mut Request) -> Result<HashMap<String, Value>, Response> {
req: Request<Body>, let body = std::mem::take(req.body_mut());
) -> Result<HashMap<String, Value>, Response<Body>> {
let (_, body) = req.into_parts();
let body_bytes = to_bytes(body, PROXY_BODY_LIMIT) let body_bytes = to_bytes(body, PROXY_BODY_LIMIT)
.await .await
.map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Invalid request body"))?; .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( async fn refresh_opencode_after_config_change(
state: &ServerState, state: &ServerState,
reason: &str, reason: &str,
) -> Result<(), Response<Body>> { ) -> Result<(), Response> {
info!("[desktop:config] Restarting OpenCode after {}", reason); info!("[desktop:config] Restarting OpenCode after {}", reason);
state.opencode.restart().await.map_err(|err| { state.opencode.restart().await.map_err(|err| {
config_error_response( config_error_response(
@@ -1233,21 +1211,146 @@ async fn refresh_opencode_after_config_change(
Ok(()) Ok(())
} }
fn extract_directory_from_request(req: &Request) -> Option<String> {
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<PathBuf, Response> {
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<Option<PathBuf>, 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<String>,
) -> Result<PathBuf, Response> {
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( async fn handle_agent_route(
state: &ServerState, state: &ServerState,
method: Method, method: Method,
req: Request<Body>, mut req: Request,
name: String, name: String,
) -> Result<Response<Body>, StatusCode> { ) -> Result<Response, StatusCode> {
// Get working directory for project-level agent detection // 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 { match method {
Method::GET => { Method::GET => {
match opencode_config::get_agent_sources(&name, Some(&working_directory)).await { match opencode_config::get_agent_sources(&name, Some(&working_directory)).await {
Ok(sources) => { 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::User => opencode_config::CommandScope::User,
opencode_config::Scope::Project => opencode_config::CommandScope::Project, opencode_config::Scope::Project => opencode_config::CommandScope::Project,
}); });
@@ -1271,12 +1374,11 @@ async fn handle_agent_route(
} }
} }
Method::POST => { Method::POST => {
let payload = match parse_request_payload(req).await { let payload = match parse_request_payload(&mut req).await {
Ok(data) => data, Ok(data) => data,
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
// Extract scope from payload if present // Extract scope from payload if present
let scope = payload let scope = payload
.get("scope") .get("scope")
@@ -1320,7 +1422,7 @@ async fn handle_agent_route(
} }
} }
Method::PATCH => { Method::PATCH => {
let payload = match parse_request_payload(req).await { let payload = match parse_request_payload(&mut req).await {
Ok(data) => data, Ok(data) => data,
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
@@ -1421,11 +1523,17 @@ struct SkillFileResponse {
content: String, content: String,
} }
async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>, StatusCode> { async fn handle_skill_list_route(
let working_directory = state.opencode.get_working_directory(); state: &ServerState,
req: Request,
) -> Result<Response, StatusCode> {
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 discovered = opencode_config::discover_skills(Some(&working_directory));
let mut skills = Vec::new(); let mut skills = Vec::new();
for skill in discovered { for skill in discovered {
match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await { 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<Response<Body>,
} }
} }
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( async fn handle_skill_route(
state: &ServerState, state: &ServerState,
method: Method, method: Method,
req: Request<Body>, mut req: Request,
name: String, name: String,
file_path: Option<String>, file_path: Option<String>,
) -> Result<Response<Body>, StatusCode> { ) -> Result<Response, StatusCode> {
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),
};
// Handle file operations: /api/config/skills/:name/files/* // Handle file operations: /api/config/skills/:name/files/*
if let Some(ref fp) = file_path { if let Some(ref fp) = file_path {
@@ -1504,11 +1618,14 @@ async fn handle_skill_route(
} }
Method::PUT => { Method::PUT => {
// Write supporting file // Write supporting file
let payload = match parse_request_payload(req).await { let payload = match parse_request_payload(&mut req).await {
Ok(data) => data, Ok(data) => data,
Err(resp) => return Ok(resp), 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 { match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
Ok(sources) => { Ok(sources) => {
@@ -1616,12 +1733,13 @@ async fn handle_skill_route(
} }
} }
Method::POST => { Method::POST => {
let payload = match parse_request_payload(req).await { let payload = match parse_request_payload(&mut req).await {
Ok(data) => data, Ok(data) => data,
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
let scope = payload.get("scope") let scope = payload
.get("scope")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(|s| match s { .and_then(|s| match s {
"project" => Some(opencode_config::SkillScope::Project), "project" => Some(opencode_config::SkillScope::Project),
@@ -1667,7 +1785,7 @@ async fn handle_skill_route(
} }
} }
Method::PATCH => { Method::PATCH => {
let payload = match parse_request_payload(req).await { let payload = match parse_request_payload(&mut req).await {
Ok(data) => data, Ok(data) => data,
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
@@ -1744,18 +1862,26 @@ async fn handle_skill_route(
async fn handle_command_route( async fn handle_command_route(
state: &ServerState, state: &ServerState,
method: Method, method: Method,
req: Request<Body>, mut req: Request,
name: String, name: String,
) -> Result<Response<Body>, StatusCode> { ) -> Result<Response, StatusCode> {
// Get working directory for project-level command detection // 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 { match method {
Method::GET => { Method::GET => {
match opencode_config::get_command_sources(&name, Some(&working_directory)).await { match opencode_config::get_command_sources(&name, Some(&working_directory)).await {
Ok(sources) => { 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::User => opencode_config::CommandScope::User,
opencode_config::Scope::Project => opencode_config::CommandScope::Project, opencode_config::Scope::Project => opencode_config::CommandScope::Project,
}); });
@@ -1779,12 +1905,11 @@ async fn handle_command_route(
} }
} }
Method::POST => { Method::POST => {
let payload = match parse_request_payload(req).await { let payload = match parse_request_payload(&mut req).await {
Ok(data) => data, Ok(data) => data,
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
// Extract scope from payload if present // Extract scope from payload if present
let scope = payload let scope = payload
.get("scope") .get("scope")
@@ -1831,7 +1956,7 @@ async fn handle_command_route(
} }
} }
Method::PATCH => { Method::PATCH => {
let payload = match parse_request_payload(req).await { let payload = match parse_request_payload(&mut req).await {
Ok(data) => data, Ok(data) => data,
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
@@ -1913,8 +2038,8 @@ async fn handle_config_routes(
state: ServerState, state: ServerState,
path: &str, path: &str,
method: Method, method: Method,
req: Request<Body>, mut req: Request,
) -> Result<Response<Body>, StatusCode> { ) -> Result<Response, StatusCode> {
if let Some(name) = path.strip_prefix("/api/config/agents/") { if let Some(name) = path.strip_prefix("/api/config/agents/") {
let trimmed = name.trim(); let trimmed = name.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -1945,13 +2070,17 @@ async fn handle_config_routes(
.map(|q| q.contains("refresh=true")) .map(|q| q.contains("refresh=true"))
.unwrap_or(false); .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; let payload = skills_catalog::get_catalog(&working_directory, refresh).await;
return Ok(json_response(StatusCode::OK, payload)); return Ok(json_response(StatusCode::OK, payload));
} }
if path == "/api/config/skills/scan" && method == Method::POST { 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, Ok(data) => data,
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
@@ -1991,7 +2120,7 @@ async fn handle_config_routes(
} }
if path == "/api/config/skills/install" && method == Method::POST { 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, Ok(data) => data,
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
@@ -2019,15 +2148,22 @@ async fn handle_config_routes(
} }
}; };
let working_directory = state.opencode.get_working_directory(); let working_directory = if install_request.scope == "project" {
let response = skills_catalog::install_skills(&working_directory, install_request).await; 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 { let status = if response.ok {
StatusCode::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") { } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") {
StatusCode::UNAUTHORIZED StatusCode::UNAUTHORIZED
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") {
StatusCode::CONFLICT
} else { } else {
StatusCode::BAD_REQUEST StatusCode::BAD_REQUEST
}; };
@@ -2037,7 +2173,7 @@ async fn handle_config_routes(
// Handle skill routes: /api/config/skills and /api/config/skills/:name // Handle skill routes: /api/config/skills and /api/config/skills/:name
if path == "/api/config/skills" && method == Method::GET { 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/") { if let Some(rest) = path.strip_prefix("/api/config/skills/") {
@@ -2059,7 +2195,6 @@ async fn handle_config_routes(
.await; .await;
} }
let trimmed = rest.trim(); let trimmed = rest.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Ok(config_error_response( return Ok(config_error_response(
@@ -2158,7 +2293,8 @@ async fn change_directory_handler(
let mut resolved_path = expand_tilde_path(requested_path); let mut resolved_path = expand_tilde_path(requested_path);
if !resolved_path.is_absolute() { 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 // Validate directory exists and is accessible
@@ -2185,51 +2321,72 @@ async fn change_directory_handler(
resolved_path = canonicalized; resolved_path = canonicalized;
} }
let current_dir = state.opencode.get_working_directory(); let path_value = resolved_path.to_string_lossy().to_string();
let is_running = state.opencode.current_port().is_some();
// 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 state
.opencode .settings
.set_working_directory(resolved_path.clone()) .update(|mut settings| {
.await if !settings.is_object() {
.map_err(|e| { settings = Value::Object(Default::default());
error!( }
"[desktop:http] ERROR: Failed to set working directory: {}",
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
state.opencode.restart().await.map_err(|e| { let mut projects = settings
error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e); .get("projects")
StatusCode::INTERNAL_SERVER_ERROR .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 { Ok(Json(DirectoryChangeResponse {
success: true, success: true,
restarted: true, restarted: false,
path: resolved_path.to_string_lossy().to_string(), path: path_value,
})) }))
} }
async fn proxy_to_opencode( async fn proxy_to_opencode(
State(state): State<ServerState>, State(state): State<ServerState>,
original: OriginalUri, req: Request,
req: Request<Body>, ) -> Result<Response, axum::http::StatusCode> {
) -> Result<Response<Body>, StatusCode> { let origin_path = req.uri().path().to_string();
let origin_path = original.0.path().to_string();
let method = req.method().clone(); let method = req.method().clone();
// Check if this is a provider auth deletion request (DELETE /api/provider/:id/auth) // 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 StatusCode::SERVICE_UNAVAILABLE
})?; })?;
let query = original.0.query(); let query = req.uri().query();
let rewritten_path = state.opencode.rewrite_path(&origin_path); let rewritten_path = state.opencode.rewrite_path(&origin_path);
let mut target = format!("http://127.0.0.1:{port}{rewritten_path}"); let mut target = format!("http://127.0.0.1:{port}{rewritten_path}");
if let Some(q) = query { 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<R, F>(&self, f: F) -> Result<(Value, R)>
where
F: FnOnce(Value) -> (Value, R),
{
let _lock = self.guard.lock().await; 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((next, result))
Ok(()) }
pub(crate) async fn update<F>(&self, f: F) -> Result<Value>
where
F: FnOnce(Value) -> Value,
{
let (next, _) = self.update_with(|current| (f(current), ())).await?;
Ok(next)
} }
pub(crate) async fn last_directory(&self) -> Result<Option<PathBuf>> { pub(crate) async fn last_directory(&self) -> Result<Option<PathBuf>> {
@@ -105,9 +105,30 @@ fn get_config_file() -> PathBuf {
get_config_dir().join("opencode.json") 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<PathBuf> {
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 { 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 /// Get custom config file path from OPENCODE_CONFIG env var
@@ -165,7 +186,9 @@ async fn read_config_file(path: &Path) -> Result<Value> {
return Ok(Value::Object(serde_json::Map::new())); 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::<serde_json::Value>(&normalized))
.map_err(|e| anyhow!("Failed to parse config: {}", e))
} }
async fn read_config_layers(working_directory: Option<&Path>) -> Result<ConfigLayers> { async fn read_config_layers(working_directory: Option<&Path>) -> Result<ConfigLayers> {
@@ -57,7 +57,7 @@ fn normalize_api_prefix(prefix: &str) -> String {
} }
impl OpenCodeManager { impl OpenCodeManager {
pub fn new_with_directory(initial_dir: Option<PathBuf>) -> Self { pub fn new_with_directory(_initial_dir: Option<PathBuf>) -> Self {
let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT") let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT")
.ok() .ok()
.and_then(|raw| raw.parse::<u16>().ok()) .and_then(|raw| raw.parse::<u16>().ok())
@@ -88,7 +88,7 @@ impl OpenCodeManager {
} }
let env = build_augmented_env(); 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("."))); .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
info!( info!(
@@ -177,11 +177,13 @@ impl OpenCodeManager {
self.graceful_stop().await self.graceful_stop().await
} }
#[allow(dead_code)]
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> { pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
*self.working_dir.write() = new_dir; *self.working_dir.write() = new_dir;
Ok(()) Ok(())
} }
#[allow(dead_code)]
pub fn get_working_directory(&self) -> PathBuf { pub fn get_working_directory(&self) -> PathBuf {
self.working_dir.read().clone() self.working_dir.read().clone()
} }
@@ -191,7 +193,7 @@ impl OpenCodeManager {
return Err(anyhow!("Cannot detect API prefix without port")); 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"]; let candidates = ["", "/api"];
for candidate in candidates { for candidate in candidates {
let base = if candidate.is_empty() { let base = if candidate.is_empty() {
@@ -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 anyhow::Result;
use futures_util::TryStreamExt; use futures_util::TryStreamExt;
@@ -10,6 +10,7 @@ use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_util::io::StreamReader; use tokio_util::io::StreamReader;
use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime; use crate::DesktopRuntime;
#[derive(Deserialize)] #[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 // 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. // has changed, reconnect so activity tracking follows the new directory.
if let SseScope::Directory(connected_dir) = &scope { if let SseScope::Directory(connected_dir) = &scope {
let current_dir = opencode.get_working_directory(); if let Some(current_dir) =
if current_dir != *connected_dir { resolve_project_directory_from_settings(runtime).await
debug!( {
"[desktop:activity] Working directory changed; reconnecting activity SSE (from {:?} to {:?})", if current_dir != *connected_dir {
connected_dir, current_dir debug!(
); "[desktop:activity] Project directory changed; reconnecting activity SSE (from {:?} to {:?})",
return Ok(()); connected_dir, current_dir
);
return Ok(());
}
} }
} }
continue; continue;
@@ -183,13 +187,34 @@ fn parse_event_envelope(raw: &str) -> Result<(EventEnvelope, Option<String>)> {
Ok((multiplexed.payload, multiplexed.directory)) Ok((multiplexed.payload, multiplexed.directory))
} }
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
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( async fn connect_activity_sse(
runtime: &DesktopRuntime, runtime: &DesktopRuntime,
client: &Client, client: &Client,
base: &str, base: &str,
) -> Result<(reqwest::Response, SseScope)> { ) -> Result<(reqwest::Response, SseScope)> {
let opencode = runtime.opencode_manager();
let global_url = format!("{base}/global/event"); let global_url = format!("{base}/global/event");
match try_connect_sse(client, &global_url, "[desktop:activity]").await { match try_connect_sse(client, &global_url, "[desktop:activity]").await {
Ok(response) => { 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 directory = working_dir.to_string_lossy().to_string();
let mut parsed = reqwest::Url::parse(&event_url)?; let mut parsed = reqwest::Url::parse(&event_url)?;
parsed parsed
@@ -45,6 +45,7 @@
"exceptionDomain": "localhost", "exceptionDomain": "localhost",
"minimumSystemVersion": "14.0", "minimumSystemVersion": "14.0",
"signingIdentity": null, "signingIdentity": null,
"entitlements": "./entitlements.plist",
"infoPlist": "Info.plist", "infoPlist": "Info.plist",
"dmg": { "dmg": {
"appPosition": { "appPosition": {
+28 -18
View File
@@ -144,22 +144,12 @@ window.opencodeDesktop = {
} }
}, },
async getSettings(): Promise<DesktopSettings> { async getSettings(): Promise<DesktopSettings> {
try { const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings');
const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings'); return result.settings;
return result.settings;
} catch (error) {
console.error('[desktop] Error loading settings:', error);
return {} as DesktopSettings;
}
}, },
async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> { async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> {
try { const result = await invoke<DesktopSettings>('save_settings', { changes });
const result = await invoke<DesktopSettings>('save_settings', { changes }); return result;
return result;
} catch (error) {
console.error('[desktop] Error updating settings:', error);
return {};
}
}, },
async restartOpenCode() { async restartOpenCode() {
try { try {
@@ -188,22 +178,42 @@ window.opencodeDesktop = {
markRendererReady() { markRendererReady() {
}, },
async requestDirectoryAccess() { async requestDirectoryAccess(directoryPath?: string) {
try { 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 { open } = await import('@tauri-apps/plugin-dialog');
const selected = await open({ const selected = await open({
directory: true, directory: true,
multiple: false, multiple: false,
title: 'Select Working Directory' title: 'Select Working Directory',
}); });
if (!selected || typeof selected !== 'string') { if (!selected || typeof selected !== 'string') {
return { success: false, error: 'Directory selection cancelled' }; return { success: false, error: 'Directory selection cancelled' };
} }
const result = await invoke<{ success: boolean; path?: string; error?: string }>('process_directory_selection', { const result = await invoke<{
path: selected success: boolean;
path?: string;
projectId?: string;
error?: string;
}>('process_directory_selection', {
path: selected,
}); });
return result; return result;
+4 -1
View File
@@ -11,10 +11,13 @@
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
}, },
"dependencies": { "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-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.0.209", "@opencode-ai/sdk": "^1.1.1",
"@pierre/diffs": "^1.0.0", "@pierre/diffs": "^1.0.0",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
+16 -3
View File
@@ -44,7 +44,7 @@ type AppProps = {
}; };
function App({ apis }: AppProps) { function App({ apis }: AppProps) {
const { initializeApp, isInitialized } = useConfigStore(); const { initializeApp, isInitialized, isConnected } = useConfigStore();
const { error, clearError, loadSessions } = useSessionStore(); const { error, clearError, loadSessions } = useSessionStore();
const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory); const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
@@ -121,11 +121,16 @@ function App({ apis }: AppProps) {
React.useEffect(() => { React.useEffect(() => {
const init = async () => { 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(); await initializeApp();
}; };
init(); init();
}, [initializeApp]); }, [initializeApp, isVSCodeRuntime]);
React.useEffect(() => { React.useEffect(() => {
if (isSwitchingDirectory) { if (isSwitchingDirectory) {
@@ -133,13 +138,21 @@ function App({ apis }: AppProps) {
} }
const syncDirectoryAndSessions = async () => { const syncDirectoryAndSessions = async () => {
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
if (isVSCodeRuntime) {
return;
}
if (!isConnected) {
return;
}
opencodeClient.setDirectory(currentDirectory); opencodeClient.setDirectory(currentDirectory);
await loadSessions(); await loadSessions();
}; };
syncDirectoryAndSessions(); syncDirectoryAndSessions();
}, [currentDirectory, isSwitchingDirectory, loadSessions]); }, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]);
useEventStream(); useEventStream();
+84 -24
View File
@@ -30,6 +30,7 @@ import { toast } from 'sonner';
import { useFileStore } from '@/stores/fileStore'; import { useFileStore } from '@/stores/fileStore';
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { isIMECompositionEvent } from '@/lib/ime';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -47,14 +48,68 @@ interface ChatInputProps {
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null; const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
/** type PermissionAction = 'allow' | 'ask' | 'deny';
* Detects if a keyboard event is part of IME composition. type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
* Uses both isComposing and keyCode === 229 (MDN recommended).
* WebKit may fire compositionend before keydown, causing isComposing to be false const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
* while keyCode remains 229, so both checks are needed. if (!Array.isArray(value)) {
*/ return null;
const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => { }
return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229; const rules: PermissionRule[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const candidate = entry as Partial<PermissionRule>;
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<string, PermissionAction | undefined> | undefined => {
const rules = asPermissionRuleset(ruleset);
if (!rules || rules.length === 0) {
return undefined;
}
const map: Record<string, PermissionAction | undefined> = {};
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<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => { export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
@@ -184,19 +239,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [agents, currentAgentName]); }, [agents, currentAgentName]);
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => { const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
const agentPermissionRaw = currentAgent?.permission?.edit; if (!currentAgent) {
let defaultMode: EditPermissionMode = 'ask'; return 'deny';
if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') {
defaultMode = agentPermissionRaw;
} }
const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false; const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask';
if (!currentAgent || !editToolConfigured) { return action;
defaultMode = 'deny';
}
return defaultMode;
}, [currentAgent]); }, [currentAgent]);
const sessionAgentEditOverride = useSessionStore( const sessionAgentEditOverride = useSessionStore(
@@ -209,8 +257,20 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [currentSessionId, currentAgentName]) }, [currentSessionId, currentAgentName])
); );
const agentWebfetchPermission = currentAgent?.permission?.webfetch; const agentWebfetchPermission = React.useMemo(() => {
const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined; if (!currentAgent) {
return undefined;
}
return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch');
}, [currentAgent]);
const agentBashPermission = React.useMemo<BashPermissionSetting | undefined>(() => {
if (!currentAgent) {
return undefined;
}
const map = buildPermissionActionMap(currentAgent.permission, 'bash');
return map ? (map as BashPermissionSetting) : undefined;
}, [currentAgent]);
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({ const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
agentDefaultEditMode, agentDefaultEditMode,
@@ -486,8 +546,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]); }, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Early return during IME composition to prevent interference with autocomplete // Early return during IME composition to prevent interference with autocomplete.
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
if (isIMECompositionEvent(e)) return; if (isIMECompositionEvent(e)) return;
if (showCommandAutocomplete && commandRef.current) { if (showCommandAutocomplete && commandRef.current) {
@@ -521,7 +581,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
} }
// Handle Enter/Ctrl+Enter based on queue mode // 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(); e.preventDefault();
const isCtrlEnter = e.ctrlKey || e.metaKey; const isCtrlEnter = e.ctrlKey || e.metaKey;
@@ -3,14 +3,14 @@ import type { Message, Part } from '@opencode-ai/sdk/v2';
import ChatMessage from './ChatMessage'; import ChatMessage from './ChatMessage';
import { PermissionCard } from './PermissionCard'; import { PermissionCard } from './PermissionCard';
import type { Permission } from '@/types/permission'; import type { PermissionRequest } from '@/types/permission';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
import { filterSyntheticParts } from '@/lib/messages/synthetic'; import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { useTurnGrouping } from './hooks/useTurnGrouping'; import { useTurnGrouping } from './hooks/useTurnGrouping';
interface MessageListProps { interface MessageListProps {
messages: { info: Message; parts: Part[] }[]; messages: { info: Message; parts: Part[] }[];
permissions: Permission[]; permissions: PermissionRequest[];
onMessageContentChange: (reason?: ContentChangeReason) => void; onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers; getAnimationHandlers: (messageId: string) => AnimationHandlers;
hasMoreAbove: boolean; hasMoreAbove: boolean;
+127 -88
View File
@@ -55,6 +55,70 @@ type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null; 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<PermissionRule>;
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<string, PermissionAction | undefined> | undefined => {
const rules = asPermissionRuleset(ruleset);
if (!rules || rules.length === 0) {
return undefined;
}
const map: Record<string, PermissionAction | undefined> = {};
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 { interface CapabilityDefinition {
key: 'tool_call' | 'reasoning'; key: 'tool_call' | 'reasoning';
icon: IconComponent; icon: IconComponent;
@@ -314,19 +378,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
}, [desktopModelQuery]); }, [desktopModelQuery]);
const currentAgent = getCurrentAgent?.(); 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; const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
if (!currentAgent || !editToolConfigured) { if (!currentAgent) {
agentDefaultEditMode = 'deny'; return 'deny';
} }
const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask';
return action;
}, [currentAgent]);
const agentWebfetchPermission = currentAgent?.permission?.webfetch; const agentWebfetchPermission = React.useMemo(() => {
const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined; if (!currentAgent) {
return undefined;
}
return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch');
}, [currentAgent]);
const agentBashPermission = React.useMemo<BashPermissionSetting | undefined>(() => {
if (!currentAgent) {
return undefined;
}
const map = buildPermissionActionMap(currentAgent.permission, 'bash');
return map ? (map as BashPermissionSetting) : undefined;
}, [currentAgent]);
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({ const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
agentDefaultEditMode, agentDefaultEditMode,
@@ -994,27 +1068,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const renderMobileAgentTooltip = () => { const renderMobileAgentTooltip = () => {
if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null; 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 hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID; const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined; const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
const getPermissionIcon = (permission?: string) => { const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
const mode: EditPermissionMode = const rules = asPermissionRuleset(currentAgent.permission) ?? [];
permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask'; const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
return renderEditModeIcon(mode, 'h-3.5 w-3.5'); 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) => { const editPermissionSummary = summarizePermission('edit');
if (permission === 'full') return 'Full'; const bashPermissionSummary = summarizePermission('bash');
if (permission === 'allow') return 'Allow'; const webfetchPermissionSummary = summarizePermission('webfetch');
if (permission === 'deny') return 'Deny';
return 'Ask';
};
return ( return (
<MobileOverlayPanel <MobileOverlayPanel
@@ -1066,24 +1140,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
</div> </div>
)} )}
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Tools</div>
{enabledTools.length > 0 ? (
<div className="flex flex-wrap gap-1 leading-tight">
{enabledTools.map((tool) => (
<span
key={tool}
className="inline-flex items-center rounded-lg bg-muted/60 px-1.5 py-0.5 typography-meta text-foreground"
>
{tool}
</span>
))}
</div>
) : (
<div className="typography-meta text-muted-foreground">All enabled</div>
)}
</div>
{} {}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5"> <div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
@@ -1092,27 +1148,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Edit</span> <span className="typography-meta text-muted-foreground/80">Edit</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{getPermissionIcon(currentAgent.permission?.edit)} {renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground"> <span className="typography-meta font-medium text-foreground">
{getPermissionLabel(currentAgent.permission?.edit)} {editPermissionSummary.label}
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Bash</span> <span className="typography-meta text-muted-foreground/80">Bash</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)} {renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground"> <span className="typography-meta font-medium text-foreground">
{getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)} {bashPermissionSummary.label}
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">WebFetch</span> <span className="typography-meta text-muted-foreground/80">WebFetch</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{getPermissionIcon(currentAgent.permission?.webfetch)} {renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground"> <span className="typography-meta font-medium text-foreground">
{getPermissionLabel(currentAgent.permission?.webfetch)} {webfetchPermissionSummary.label}
</span> </span>
</div> </div>
</div> </div>
@@ -1983,27 +2039,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ 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 hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID; const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined; const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
const getPermissionIcon = (permission?: string) => { const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
const mode: EditPermissionMode = const rules = asPermissionRuleset(currentAgent.permission) ?? [];
permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask'; const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
return renderEditModeIcon(mode, 'h-3.5 w-3.5'); 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) => { const editPermissionSummary = summarizePermission('edit');
if (permission === 'full') return 'Full'; const bashPermissionSummary = summarizePermission('bash');
if (permission === 'allow') return 'Allow'; const webfetchPermissionSummary = summarizePermission('webfetch');
if (permission === 'deny') return 'Deny';
return 'Ask';
};
return ( return (
<TooltipContent align="start" sideOffset={8} className="max-w-[280px]"> <TooltipContent align="start" sideOffset={8} className="max-w-[280px]">
@@ -2053,50 +2109,33 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
</div> </div>
)} )}
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Tools</span>
{enabledTools.length > 0 ? (
<div className="flex flex-wrap gap-1 leading-tight">
{enabledTools.map((tool) => (
<span
key={tool}
className="inline-flex items-center rounded-lg bg-muted/60 px-1.5 py-0.5 typography-meta text-foreground"
>
{tool}
</span>
))}
</div>
) : (
<span className="typography-meta text-muted-foreground">All enabled</span>
)}
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Permissions</span> <span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Permissions</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">Edit</span> <span className="typography-meta text-muted-foreground/80 w-16">Edit</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{getPermissionIcon(currentAgent.permission?.edit)} {renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12"> <span className="typography-meta font-medium text-foreground w-12">
{getPermissionLabel(currentAgent.permission?.edit)} {editPermissionSummary.label}
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">Bash</span> <span className="typography-meta text-muted-foreground/80 w-16">Bash</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)} {renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12"> <span className="typography-meta font-medium text-foreground w-12">
{getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)} {bashPermissionSummary.label}
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">WebFetch</span> <span className="typography-meta text-muted-foreground/80 w-16">WebFetch</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{getPermissionIcon(currentAgent.permission?.webfetch)} {renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12"> <span className="typography-meta font-medium text-foreground w-12">
{getPermissionLabel(currentAgent.permission?.webfetch)} {webfetchPermissionSummary.label}
</span> </span>
</div> </div>
</div> </div>
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react'; import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
import { cn } from '@/lib/utils'; 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 { useSessionStore } from '@/stores/useSessionStore';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -10,7 +10,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { DiffPreview, WritePreview } from './DiffPreview'; import { DiffPreview, WritePreview } from './DiffPreview';
interface PermissionCardProps { interface PermissionCardProps {
permission: Permission; permission: PermissionRequest;
onResponse?: (response: 'once' | 'always' | 'reject') => void; onResponse?: (response: 'once' | 'always' | 'reject') => void;
} }
@@ -82,7 +82,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
return null; return null;
} }
const toolName = permission.type || 'Unknown Tool'; const toolName = permission.permission || 'unknown';
const tool = toolName.toLowerCase(); const tool = toolName.toLowerCase();
const getMeta = (key: string, fallback: string = ''): string => { const getMeta = (key: string, fallback: string = ''): string => {
@@ -106,9 +106,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
const description = getMeta('description'); const description = getMeta('description');
const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path'); const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path');
const timeout = getMetaNum('timeout'); const timeout = getMetaNum('timeout');
const commandInTitle = permission.title === command;
return ( return (
<> <>
{description && ( {description && (
@@ -125,7 +123,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
</div> </div>
)} )}
{} {}
{command && !commandInTitle && ( {command && (
<div> <div>
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
@@ -329,110 +327,15 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
{} {}
<div className="px-2 py-2"> <div className="px-2 py-2">
{/* Show patterns being requested */} {permission.patterns.length > 0 && (
{(permission.patterns as string[]) && (permission.patterns as string[]).length > 0 && (
<div className="mb-2"> <div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Patterns:</div> <div className="typography-meta text-muted-foreground mb-1">Patterns:</div>
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all"> <code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
{(permission.patterns as string[]).join(", ")} {permission.patterns.join(", ")}
</code> </code>
</div> </div>
)} )}
{!((permission.patterns as string[]) && (permission.patterns as string[]).length > 0) &&
(permission.pattern as string | string[]) &&
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Pattern:</div>
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
{Array.isArray(permission.pattern) ? permission.pattern.join(", ") : permission.pattern}
</code>
</div>
}
{(() => {
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 (
<div className="mb-3">
<SyntaxHighlighter
language={primaryLanguage}
style={syntaxTheme}
PreTag="div"
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
overflow: 'visible'
}}
codeTagProps={{
style: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word'
}
}}
wrapLongLines={true}
>
{primaryContent}
</SyntaxHighlighter>
</div>
);
}
if (titleMatchesContent && primaryContent && !shouldHighlight) {
return (
<div className="mb-3">
<code className="typography-ui-label px-2 py-1 bg-muted/30 rounded block break-all">
{primaryContent}
</code>
</div>
);
}
if (permission.title) {
return (
<div className={cn(
"typography-ui-label text-foreground mb-3",
(shouldHighlight || primaryContent) && "font-mono"
)}>
{permission.title}
</div>
);
}
return null;
})()}
{}
{renderToolContent()} {renderToolContent()}
</div> </div>
@@ -460,7 +363,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
Allow Once Allow Once
</button> </button>
{(permission.always as string[]) && (permission.always as string[]).length > 0 ? ( {permission.always.length > 0 ? (
<button <button
onClick={() => handleResponse('always')} onClick={() => handleResponse('always')}
disabled={isResponding} disabled={isResponding}
@@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react'; import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Permission, PermissionResponse } from '@/types/permission'; import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
interface PermissionRequestProps { interface PermissionRequestProps {
permission: Permission; permission: PermissionRequestPayload;
onResponse?: (response: 'once' | 'always' | 'reject') => void; onResponse?: (response: 'once' | 'always' | 'reject') => void;
} }
@@ -35,7 +35,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
const command = typeof permission.metadata.command === 'string' const command = typeof permission.metadata.command === 'string'
? permission.metadata.command ? permission.metadata.command
: permission.title; : (permission.patterns?.[0] ?? permission.permission);
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -230,7 +230,6 @@ const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
}; };
const stripTaskMetadataFromOutput = (output: string): string => { const stripTaskMetadataFromOutput = (output: string): string => {
// OpenCode appends a non-user-facing session marker for task tools.
// Strip only a trailing <task_metadata>...</task_metadata> block. // Strip only a trailing <task_metadata>...</task_metadata> block.
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
}; };
@@ -33,6 +33,7 @@ export const VSCodeLayout: React.FC = () => {
const messages = useSessionStore((state) => state.messages); const messages = useSessionStore((state) => state.messages);
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized); const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
const [isInitializing, setIsInitializing] = React.useState<boolean>(false); const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
const lastBootstrapAttemptAt = React.useRef<number>(0);
// Navigate to chat when a session is selected // Navigate to chat when a session is selected
React.useEffect(() => { React.useEffect(() => {
@@ -54,6 +55,16 @@ export const VSCodeLayout: React.FC = () => {
// Listen for connection status changes // Listen for connection status changes
React.useEffect(() => { React.useEffect(() => {
// Catch up with the latest status even if the extension posted the connection message
// before this component registered the event listener.
const current =
(typeof window !== 'undefined'
? (window 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 handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail; const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
const status = detail?.status; const status = detail?.status;
@@ -88,12 +99,43 @@ export const VSCodeLayout: React.FC = () => {
if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') { if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') {
return; return;
} }
const now = Date.now();
if (now - lastBootstrapAttemptAt.current < 750) {
return;
}
lastBootstrapAttemptAt.current = now;
setIsInitializing(true); setIsInitializing(true);
try { try {
const debugEnabled = (() => {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem('openchamber_stream_debug') === '1';
} catch {
return false;
}
})();
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] attempt', { configInitialized });
if (!configInitialized) { if (!configInitialized) {
await initializeConfig(); await initializeConfig();
} }
const configState = useConfigStore.getState();
// If OpenCode is still warming up, the initial provider/agent loads can fail and be swallowed by retries.
// Only mark bootstrap complete when core datasets are present so we keep retrying on cold starts.
if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) {
return;
}
await loadSessions(); await loadSessions();
const sessionsError = useSessionStore.getState().error;
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', {
providers: configState.providers.length,
agents: configState.agents.length,
sessions: useSessionStore.getState().sessions.length,
sessionsError,
});
if (typeof sessionsError === 'string' && sessionsError.length > 0) {
return;
}
setHasInitializedOnce(true); setHasInitializedOnce(true);
} catch { } catch {
// Ignore bootstrap failures // Ignore bootstrap failures
@@ -262,5 +304,3 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
</div> </div>
); );
}; };
@@ -4,7 +4,6 @@ import {
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
@@ -36,25 +35,51 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
}) => { }) => {
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const loadAgents = useConfigStore((state) => state.loadAgents); const loadAgents = useConfigStore((state) => state.loadAgents);
const defaultAgentName = useConfigStore((state) => state.currentAgentName);
const agents = getVisibleAgents(); const agents = getVisibleAgents();
const selectableAgents = React.useMemo(
() => agents.filter((agent) => agent.mode !== 'subagent'),
[agents]
);
// Load agents on mount // Load agents on mount
React.useEffect(() => { React.useEffect(() => {
loadAgents(); loadAgents();
}, [loadAgents]); }, [loadAgents]);
// Use empty string to represent "no agent" selection // Ensure we always have a valid selection (defaults to current default agent, then first selectable agent).
const handleValueChange = (newValue: string) => { React.useEffect(() => {
onChange(newValue === '__none__' ? '' : newValue); if (disabled) {
}; return;
}
// Convert empty value to __none__ for the Select component (which doesn't handle empty strings well) const trimmedValue = value.trim();
const selectValue = value || '__none__'; if (trimmedValue.length > 0 && selectableAgents.some((agent) => agent.name === trimmedValue)) {
return;
}
const candidateDefault =
typeof defaultAgentName === 'string' && defaultAgentName.trim().length > 0
? defaultAgentName.trim()
: null;
if (candidateDefault && selectableAgents.some((agent) => agent.name === candidateDefault)) {
onChange(candidateDefault);
return;
}
const firstAgent = selectableAgents[0]?.name;
if (firstAgent) {
onChange(firstAgent);
}
}, [defaultAgentName, disabled, onChange, selectableAgents, value]);
const selectValue = value.trim().length > 0 ? value : undefined;
return ( return (
<Select <Select
value={selectValue} value={selectValue}
onValueChange={handleValueChange} onValueChange={onChange}
disabled={disabled} disabled={disabled}
> >
<SelectTrigger <SelectTrigger
@@ -62,20 +87,12 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
size="lg" size="lg"
className={className ?? 'max-w-full typography-meta text-foreground'} className={className ?? 'max-w-full typography-meta text-foreground'}
> >
<SelectValue placeholder="Select an agent (optional)" /> <SelectValue placeholder="Select an agent" />
</SelectTrigger> </SelectTrigger>
<SelectContent fitContent> <SelectContent fitContent>
<SelectGroup> {selectableAgents.length > 0 && (
<SelectLabel>Default</SelectLabel>
<SelectItem value="__none__" className="w-auto whitespace-nowrap">
No agent (default)
</SelectItem>
</SelectGroup>
{agents.length > 0 && (
<SelectGroup> <SelectGroup>
<SelectLabel>Agents</SelectLabel> {selectableAgents.map((agent) => (
{agents.map((agent) => (
<SelectItem <SelectItem
key={agent.name} key={agent.name}
value={agent.name} value={agent.name}
@@ -126,6 +126,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
id, id,
}) => { }) => {
const { branches, isLoading, isGitRepository } = useBranchOptions(directory); const { branches, isLoading, isGitRepository } = useBranchOptions(directory);
const selectedLabel = React.useMemo(() => {
return branches.find((option) => option.value === value)?.label ?? null;
}, [branches, value]);
// Update value if it's no longer valid // Update value if it's no longer valid
React.useEffect(() => { React.useEffect(() => {
@@ -149,9 +152,11 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
size="lg" size="lg"
className={className ?? 'max-w-full typography-meta text-foreground'} className={className ?? 'max-w-full typography-meta text-foreground'}
> >
<SelectValue {selectedLabel ? (
placeholder={isLoading ? 'Loading branches…' : 'Select a branch'} <SelectValue>{selectedLabel}</SelectValue>
/> ) : (
<SelectValue placeholder={isLoading ? 'Loading branches…' : 'Select a branch'} />
)}
</SelectTrigger> </SelectTrigger>
<SelectContent fitContent> <SelectContent fitContent>
<SelectGroup> <SelectGroup>
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { isIMECompositionEvent } from '@/lib/ime';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useModelLists } from '@/hooks/useModelLists'; import { useModelLists } from '@/hooks/useModelLists';
import type { ModelMetadata } from '@/types'; import type { ModelMetadata } from '@/types';
@@ -325,6 +326,9 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
// Handle keyboard navigation // Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (isIMECompositionEvent(e)) {
return;
}
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -320,7 +320,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
id="multirun-agent" id="multirun-agent"
/> />
<p className="typography-micro text-muted-foreground"> <p className="typography-micro text-muted-foreground">
Optional agent to use for all runs. Defaults to your configured default agent.
</p> </p>
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -18,7 +18,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react'; 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 { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
@@ -30,6 +30,103 @@ interface AgentsSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
} }
type PermissionAction = 'allow' | 'ask' | 'deny';
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
type PermissionConfigValue = PermissionAction | Record<string, PermissionAction>;
// 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<PermissionRule>;
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<string, Record<string, PermissionAction>> = {};
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<string, PermissionConfigValue> = {};
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<AgentsSidebarProps> = ({ onItemSelect }) => { export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) => {
const [renameDialogAgent, setRenameDialogAgent] = React.useState<Agent | null>(null); const [renameDialogAgent, setRenameDialogAgent] = React.useState<Agent | null>(null);
const [renameNewName, setRenameNewName] = React.useState(''); const [renameNewName, setRenameNewName] = React.useState('');
@@ -132,12 +229,9 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
// Set draft with prefilled values from source agent // Set draft with prefilled values from source agent
const extAgent = agent as Agent & { scope?: AgentScope }; const extAgent = agent as Agent & { scope?: AgentScope };
// Convert model object to string if needed (SDK type vs API type difference) const modelStr = agent.model?.providerID && agent.model?.modelID
const modelStr = typeof agent.model === 'string' ? `${agent.model.providerID}/${agent.model.modelID}`
? agent.model : null;
: agent.model?.providerID && agent.model?.modelID
? `${agent.model.providerID}/${agent.model.modelID}`
: undefined;
const draftAgent = agent as Agent & { disable?: boolean }; const draftAgent = agent as Agent & { disable?: boolean };
setAgentDraft({ setAgentDraft({
name: newName, name: newName,
@@ -148,8 +242,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
top_p: agent.topP, top_p: agent.topP,
prompt: agent.prompt, prompt: agent.prompt,
mode: agent.mode, mode: agent.mode,
tools: agent.tools, permission: rulesetToPermissionConfig(agent.permission),
permission: agent.permission,
disable: draftAgent.disable, disable: draftAgent.disable,
}); });
setSelectedAgent(newName); setSelectedAgent(newName);
@@ -185,12 +278,9 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
} }
// Create new agent with new name and all existing config // 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 = renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID
const renameModelStr = typeof renameDialogAgent.model === 'string' ? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}`
? renameDialogAgent.model : null;
: renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID
? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}`
: undefined;
const renameExt = renameDialogAgent as Agent & { scope?: AgentScope; disable?: boolean }; const renameExt = renameDialogAgent as Agent & { scope?: AgentScope; disable?: boolean };
const success = await createAgent({ const success = await createAgent({
name: sanitizedName, name: sanitizedName,
@@ -200,8 +290,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
top_p: renameDialogAgent.topP, top_p: renameDialogAgent.topP,
prompt: renameDialogAgent.prompt, prompt: renameDialogAgent.prompt,
mode: renameDialogAgent.mode, mode: renameDialogAgent.mode,
tools: renameDialogAgent.tools, permission: rulesetToPermissionConfig(renameDialogAgent.permission),
permission: renameDialogAgent.permission,
disable: renameExt.disable, disable: renameExt.disable,
scope: renameExt.scope, scope: renameExt.scope,
}); });
@@ -126,7 +126,6 @@ export const ProvidersPage: React.FC = () => {
const providers = useConfigStore((state) => state.providers); const providers = useConfigStore((state) => state.providers);
const selectedProviderId = useConfigStore((state) => state.selectedProviderId); const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const loadProviders = useConfigStore((state) => state.loadProviders);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata); const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState<Record<string, AuthMethod[]>>({}); const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState<Record<string, AuthMethod[]>>({});
@@ -275,8 +274,7 @@ export const ProvidersPage: React.FC = () => {
toast.success('API key saved'); toast.success('API key saved');
setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' })); setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' }));
await reloadOpenCodeConfiguration(); await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
await loadProviders();
setSelectedProvider(providerId); setSelectedProvider(providerId);
} catch (error) { } catch (error) {
console.error('Failed to save API key:', error); console.error('Failed to save API key:', error);
@@ -375,8 +373,7 @@ export const ProvidersPage: React.FC = () => {
toast.success('OAuth connection completed'); toast.success('OAuth connection completed');
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' })); setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
setPendingOAuth(null); setPendingOAuth(null);
await reloadOpenCodeConfiguration(); await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
await loadProviders();
setSelectedProvider(providerId); setSelectedProvider(providerId);
} catch (error) { } catch (error) {
console.error('Failed to complete OAuth flow:', error); console.error('Failed to complete OAuth flow:', error);
@@ -423,8 +420,7 @@ export const ProvidersPage: React.FC = () => {
} }
toast.success('Provider disconnected'); toast.success('Provider disconnected');
await reloadOpenCodeConfiguration(); await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
await loadProviders();
} catch (error) { } catch (error) {
console.error('Failed to disconnect provider:', error); console.error('Failed to disconnect provider:', error);
toast.error('Failed to disconnect provider'); toast.error('Failed to disconnect provider');
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DirectoryTree } from './DirectoryTree'; import { DirectoryTree } from './DirectoryTree';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { cn, formatPathForDisplay } from '@/lib/utils'; import { cn, formatPathForDisplay } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -33,7 +34,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
open, open,
onOpenChange, onOpenChange,
}) => { }) => {
const { currentDirectory, homeDirectory, setDirectory, isHomeReady } = useDirectoryStore(); const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
const { addProject, getActiveProject } = useProjectsStore();
const [pendingPath, setPendingPath] = React.useState<string | null>(null); const [pendingPath, setPendingPath] = React.useState<string | null>(null);
const [pathInputValue, setPathInputValue] = React.useState(''); const [pathInputValue, setPathInputValue] = React.useState('');
const [hasUserSelection, setHasUserSelection] = React.useState(false); const [hasUserSelection, setHasUserSelection] = React.useState(false);
@@ -67,12 +69,13 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
if (open) { if (open) {
setHasUserSelection(false); setHasUserSelection(false);
setIsConfirming(false); setIsConfirming(false);
// Initialize with current directory // Initialize with active project or current directory
const initialPath = currentDirectory || homeDirectory || ''; const activeProject = getActiveProject();
const initialPath = activeProject?.path || currentDirectory || homeDirectory || '';
setPendingPath(initialPath); setPendingPath(initialPath);
setPathInputValue(formatPath(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) // Set initial pending path to home when ready (only if not yet selected)
React.useEffect(() => { React.useEffect(() => {
@@ -104,13 +107,10 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
if (!targetPath || isConfirming) { if (!targetPath || isConfirming) {
return; return;
} }
if (targetPath === currentDirectory) {
handleClose();
return;
}
setIsConfirming(true); setIsConfirming(true);
try { try {
let resolvedPath = targetPath; let resolvedPath = targetPath;
let projectId: string | undefined;
if (isDesktop) { if (isDesktop) {
const accessResult = await requestAccess(targetPath); const accessResult = await requestAccess(targetPath);
@@ -121,6 +121,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
return; return;
} }
resolvedPath = accessResult.path ?? targetPath; resolvedPath = accessResult.path ?? targetPath;
projectId = accessResult.projectId;
const startResult = await startAccessing(resolvedPath); const startResult = await startAccessing(resolvedPath);
if (!startResult.success) { if (!startResult.success) {
@@ -131,7 +132,14 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
} }
} }
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(); handleClose();
} catch (error) { } catch (error) {
toast.error('Failed to select directory', { toast.error('Failed to select directory', {
@@ -141,11 +149,10 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
setIsConfirming(false); setIsConfirming(false);
} }
}, [ }, [
currentDirectory, addProject,
handleClose, handleClose,
isDesktop, isDesktop,
requestAccess, requestAccess,
setDirectory,
startAccessing, startAccessing,
isConfirming, isConfirming,
]); ]);
@@ -200,9 +207,9 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const dialogHeader = ( const dialogHeader = (
<DialogHeader className="flex-shrink-0 px-4 pb-2 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-3 sm:pt-0"> <DialogHeader className="flex-shrink-0 px-4 pb-2 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-3 sm:pt-0">
<DialogTitle>Select project directory</DialogTitle> <DialogTitle>Add project directory</DialogTitle>
<DialogDescription className="hidden sm:block"> <DialogDescription className="hidden sm:block">
Choose the working directory for sessions and OpenCode operations. Choose a folder to add as a project.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
); );
@@ -304,7 +311,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
disabled={isConfirming || !hasUserSelection || (!pendingPath && !pathInputValue.trim())} disabled={isConfirming || !hasUserSelection || (!pendingPath && !pathInputValue.trim())}
className="flex-1 sm:flex-none sm:w-auto sm:min-w-[140px]" className="flex-1 sm:flex-none sm:w-auto sm:min-w-[140px]"
> >
{isConfirming ? 'Applying...' : 'Open Directory'} {isConfirming ? 'Adding...' : 'Add Project'}
</Button> </Button>
</> </>
); );
@@ -314,7 +321,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
<MobileOverlayPanel <MobileOverlayPanel
open={open} open={open}
onClose={() => onOpenChange(false)} onClose={() => onOpenChange(false)}
title="Select project directory" title="Add project directory"
className="max-w-full" className="max-w-full"
contentMaxHeightClassName="max-h-[min(70vh,520px)] h-[min(70vh,520px)]" contentMaxHeightClassName="max-h-[min(70vh,520px)] h-[min(70vh,520px)]"
footer={<div className="flex flex-row gap-2">{renderActionButtons()}</div>} footer={<div className="flex flex-row gap-2">{renderActionButtons()}</div>}
@@ -38,6 +38,9 @@ import {
import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi'; import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; 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 { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
@@ -122,6 +125,7 @@ export const SessionDialogs: React.FC = () => {
const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false); const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false);
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null); const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false); const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false);
const [worktreeManagerProjectId, setWorktreeManagerProjectId] = React.useState<string | null>(null);
const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set()); const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set());
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null); const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]); const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
@@ -140,13 +144,22 @@ export const SessionDialogs: React.FC = () => {
getWorktreeMetadata, getWorktreeMetadata,
isLoading, isLoading,
} = useSessionStore(); } = 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 { agents } = useConfigStore();
const { isSessionCreateDialogOpen, setSessionCreateDialogOpen } = useUIStore(); const { isSessionCreateDialogOpen, setSessionCreateDialogOpen } = useUIStore();
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
const useMobileOverlay = isMobile || isTablet || hasTouchInput; 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 sanitizedNewBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]);
const worktreeTargetBranch = React.useMemo( const worktreeTargetBranch = React.useMemo(
() => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName), () => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName),
@@ -213,15 +226,75 @@ export const SessionDialogs: React.FC = () => {
loadSessions(); loadSessions();
}, [loadSessions, currentDirectory]); }, [loadSessions, currentDirectory]);
const projectsKey = React.useMemo(
() => projects.map((project) => `${project.id}:${project.path}`).join('|'),
[projects],
);
const lastProjectsKeyRef = React.useRef(projectsKey);
React.useEffect(() => { React.useEffect(() => {
if (!hasShownInitialDirectoryPrompt && isHomeReady && !hasPersistedDirectory) { if (projectsKey === lastProjectsKeyRef.current) {
setIsDirectoryDialogOpen(true); return;
setHasShownInitialDirectoryPrompt(true);
} }
}, [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(() => { React.useEffect(() => {
if (!isSessionCreateDialogOpen) { if (!isSessionCreateDialogOpen) {
setWorktreeManagerProjectId(null);
setWorktreeCreateMode('new'); setWorktreeCreateMode('new');
setBranchName(''); setBranchName('');
setExistingWorktreeBranch(''); setExistingWorktreeBranch('');
@@ -372,7 +445,9 @@ export const SessionDialogs: React.FC = () => {
}, []); }, []);
React.useEffect(() => { 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'); setWorktreeCreateMode('new');
setBranchName(''); setBranchName('');
setExistingWorktreeBranch(''); setExistingWorktreeBranch('');
@@ -555,6 +630,9 @@ export const SessionDialogs: React.FC = () => {
setSessionDirectory(session.id, metadata.path); setSessionDirectory(session.id, metadata.path);
setWorktreeMetadata(session.id, createdMetadata); setWorktreeMetadata(session.id, createdMetadata);
// Ensure directory-scoped caches and session lists include the new worktree.
setDirectory(metadata.path, { showOverlay: false });
await refreshWorktrees(); await refreshWorktrees();
setBranchName(''); setBranchName('');
setExistingWorktreeBranch(''); setExistingWorktreeBranch('');
File diff suppressed because it is too large Load Diff
@@ -34,13 +34,13 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
return ( return (
<div <div
className={cn("relative flex flex-col min-h-0 w-full overflow-hidden", outerClassName)} className={cn("relative flex flex-col min-h-0 w-full overflow-hidden overscroll-none", outerClassName)}
data-keyboard-avoid={keyboardAvoid ? "true" : undefined} data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
> >
<Component <Component
ref={containerRef as React.Ref<HTMLElement>} ref={containerRef as React.Ref<HTMLElement>}
className={cn( 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", 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", disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className className
+138 -20
View File
@@ -2,8 +2,20 @@ import React from 'react';
import { cn, getModifierLabel } from '@/lib/utils'; import { cn, getModifierLabel } from '@/lib/utils';
import { SIDEBAR_SECTIONS } from '@/constants/sidebar'; import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
import type { SidebarSection } 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 { 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 { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
import { AgentsPage } from '@/components/sections/agents/AgentsPage'; import { AgentsPage } from '@/components/sections/agents/AgentsPage';
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar'; import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
@@ -99,8 +111,61 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return () => observer.disconnect(); 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; 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) // Update proportional width on window resize (if not manually resized)
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -341,26 +406,79 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
})} })}
</div> </div>
{onClose && ( {(onClose || showProjectSwitcher) && (
<div className={cn('flex items-center', isMobile ? '' : 'pr-3')}> <div className={cn('flex items-center gap-2', isMobile ? '' : 'pr-3')}>
<Tooltip delayDuration={500}> {showProjectSwitcher && (
<TooltipTrigger asChild> <DropdownMenu>
<button <DropdownMenuTrigger asChild>
type="button" {isMobile ? (
onClick={onClose} <button
aria-label="Close settings" type="button"
className={cn( aria-label="Switch project"
'inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', title={activeProjectLabel}
!isMobile && 'app-region-no-drag' className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiFolderLine className="h-5 w-5" />
</button>
) : (
<button
type="button"
aria-label="Switch project"
title={activeProjectLabel}
className={cn(
'flex h-9 max-w-[18rem] items-center gap-1.5 bg-transparent px-2 text-foreground outline-none hover:text-foreground/80 focus-visible:ring-2 focus-visible:ring-ring',
!isMobile && 'app-region-no-drag'
)}
>
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium">{activeProjectLabel}</span>
<RiArrowDownSLine className="size-4 opacity-50" />
</button>
)} )}
> </DropdownMenuTrigger>
<RiCloseLine className="h-5 w-5" /> <DropdownMenuContent align="end" className="w-auto">
</button> <DropdownMenuRadioGroup
</TooltipTrigger> value={activeProject?.id ?? ''}
<TooltipContent> onValueChange={(value) => {
<p>Close Settings ({shortcutKey}+,)</p> if (!value) return;
</TooltipContent> setActiveProject(value);
</Tooltip> }}
>
{sortedProjects.map((project) => {
const rawLabel = project.label?.trim()
? project.label.trim()
: (project.path.split('/').filter(Boolean).pop() || project.path);
const label = formatProjectLabel(rawLabel);
return (
<DropdownMenuRadioItem key={project.id} value={project.id}>
<span className="min-w-0 truncate typography-ui">{label}</span>
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{onClose && (
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClose}
aria-label="Close settings"
className={cn(
'inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
!isMobile && 'app-region-no-drag'
)}
>
<RiCloseLine className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Close Settings ({shortcutKey}+,)</p>
</TooltipContent>
</Tooltip>
)}
</div> </div>
)} )}
</div> </div>
@@ -3,7 +3,9 @@ import {
RiGitBranchLine, RiGitBranchLine,
RiArrowDownSLine, RiArrowDownSLine,
RiCheckLine, RiCheckLine,
RiMore2Line,
} from '@remixicon/react'; } from '@remixicon/react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
@@ -11,6 +13,14 @@ import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { ChatContainer } from '@/components/chat/ChatContainer'; import { ChatContainer } from '@/components/chat/ChatContainer';
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -27,8 +37,10 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
group, group,
className, className,
}) => { }) => {
const { selectedSessionId, selectSession } = useAgentGroupsStore(); const { selectedSessionId, selectSession, deleteGroupWorktree, keepOnlyGroupWorktree } = useAgentGroupsStore();
const { setCurrentSession, currentSessionId } = useSessionStore(); const { setCurrentSession, currentSessionId } = useSessionStore();
const [worktreeDialog, setWorktreeDialog] = React.useState<null | { kind: 'remove' | 'keepOnly'; path: string; label: string }>(null);
const [isProcessing, setIsProcessing] = React.useState(false);
// Find the currently selected session // Find the currently selected session
const selectedSession = React.useMemo(() => { const selectedSession = React.useMemo(() => {
@@ -70,6 +82,47 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
// Check if the current OpenCode session matches the selected agent group session // Check if the current OpenCode session matches the selected agent group session
const isSessionSynced = selectedSession?.id === currentSessionId; 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 ( return (
<div className={cn('flex h-full flex-col bg-background', className)}> <div className={cn('flex h-full flex-col bg-background', className)}>
{/* Header */} {/* Header */}
@@ -90,7 +143,8 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
{/* Model Selector Dropdown */} {/* Model Selector Dropdown */}
{group.sessions.length > 0 && ( {group.sessions.length > 0 && (
<div className="mt-3"> <div className="mt-3 flex items-center gap-2">
<div className="flex-1 min-w-0">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -154,9 +208,64 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-10 w-10 flex-shrink-0" aria-label="Worktree actions">
<RiMore2Line className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[220px]">
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
void handleRemoveSelectedWorktree();
}}
variant="destructive"
>
Remove this worktree
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
void handleKeepOnlySelectedWorktree();
}}
>
Leave this one, remove others
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
)} )}
</div> </div>
<Dialog open={Boolean(worktreeDialog)} onOpenChange={(open) => { if (!open) setWorktreeDialog(null); }}>
<DialogContent className="max-w-md" keyboardAvoid>
<DialogHeader>
<DialogTitle>
{worktreeDialog?.kind === 'remove' ? 'Remove worktree' : 'Remove other worktrees'}
</DialogTitle>
<DialogDescription>
{worktreeDialog?.kind === 'remove'
? <>Remove <span className="text-foreground font-medium">{worktreeDialog?.label}</span>? This deletes all sessions in that worktree and removes the worktree itself.</>
: <>Keep <span className="text-foreground font-medium">{worktreeDialog?.label}</span> and remove the other worktrees in <span className="text-foreground font-medium">{group.name}</span>.</>}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setWorktreeDialog(null)} disabled={isProcessing}>
Cancel
</Button>
<Button
variant={worktreeDialog?.kind === 'remove' ? 'destructive' : 'default'}
onClick={() => void handleConfirmWorktreeAction()}
disabled={isProcessing}
>
{isProcessing ? 'Working…' : worktreeDialog?.kind === 'remove' ? 'Remove' : 'Remove others'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Chat Content */} {/* Chat Content */}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
@@ -16,6 +16,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect'; import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect';
import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector'; import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector';
import { AgentSelector } from '@/components/multirun/AgentSelector'; import { AgentSelector } from '@/components/multirun/AgentSelector';
import { isIMECompositionEvent } from '@/lib/ime';
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun'; import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
/** Max file size in bytes (10MB) */ /** Max file size in bytes (10MB) */
@@ -23,16 +24,6 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
/** Max number of concurrent runs */ /** Max number of concurrent runs */
const MAX_MODELS = 5; 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 */ /** Attached file for agent manager */
interface AttachedFile { interface AttachedFile {
id: string; id: string;
@@ -191,7 +182,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
if (isIMECompositionEvent(e)) return; if (isIMECompositionEvent(e)) return;
// Enter submits if valid, Shift+Enter adds newline // Enter submits if valid, Shift+Enter adds newline
if (e.key === 'Enter' && !e.shiftKey && !isIMECompositionEvent(e)) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (isValid && !isSubmittingOrCreating) { if (isValid && !isSubmittingOrCreating) {
handleSubmit(e as unknown as React.FormEvent); handleSubmit(e as unknown as React.FormEvent);
@@ -247,7 +238,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
onChange={setSelectedAgent} onChange={setSelectedAgent}
/> />
<p className="typography-micro text-muted-foreground"> <p className="typography-micro text-muted-foreground">
Optional agent to use for all runs Defaults to your configured default agent
</p> </p>
</div> </div>
@@ -10,12 +10,6 @@ import { toast } from 'sonner';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -24,6 +18,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore'; import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -50,121 +50,105 @@ interface AgentGroupItemProps {
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSelect }) => { const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSelect }) => {
const [menuOpen, setMenuOpen] = React.useState(false); 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 [isDeleting, setIsDeleting] = React.useState(false);
const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup); const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup);
const handleDelete = async () => { const handleDeleteGroup = React.useCallback(async () => {
if (isDeleting) return;
setIsDeleting(true); setIsDeleting(true);
try { toast.info(`Deleting "${group.name}"...`);
const { success, deletedCount, failedCount } = await deleteGroup(group.name); const ok = await deleteGroup(group.name);
if (ok) {
if (success) { toast.success(`Deleted "${group.name}"`);
toast.success(`Deleted agent group "${group.name}"`, { } else {
description: `${deletedCount} session${deletedCount !== 1 ? 's' : ''} removed with worktrees archived.`, const error = useAgentGroupsStore.getState().error;
}); toast.error(error || `Failed to delete "${group.name}"`);
} 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);
} }
}; setIsDeleting(false);
setConfirmOpen(false);
}, [deleteGroup, group.name, isDeleting]);
return ( return (
<div <>
className={cn( <div
'group relative flex items-center rounded-md px-1.5 py-1.5 cursor-pointer', className={cn(
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6', 'group relative flex items-center rounded-md px-1.5 py-1.5 cursor-pointer',
)} isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6',
onClick={onSelect} )}
> onClick={onSelect}
<div className="flex min-w-0 flex-1 items-center gap-2"> >
<button <div className="flex min-w-0 flex-1 items-center gap-2">
type="button" <button
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" type="button"
> className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
<span className="truncate typography-ui-label font-normal text-foreground"> >
{group.name} <span className="truncate typography-ui-label font-normal text-foreground">
</span> {group.name}
<div className="flex items-center gap-2">
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
<RiGitBranchLine className="h-3 w-3" />
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}
</span>
<span className="typography-micro text-muted-foreground/60">
{formatRelativeTime(group.lastActive)}
</span> </span>
<div className="flex items-center gap-2">
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
<RiGitBranchLine className="h-3 w-3" />
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}
</span>
<span className="typography-micro text-muted-foreground/60">
{formatRelativeTime(group.lastActive)}
</span>
</div>
</button>
<div className="flex items-center gap-1.5 self-stretch">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-3.5 w-[18px] items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
'opacity-0 group-hover:opacity-100',
menuOpen && 'opacity-100',
)}
aria-label="Group menu"
onClick={(e) => e.stopPropagation()}
>
<RiMore2Line className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[140px]">
<DropdownMenuItem
variant="destructive"
onSelect={(e) => {
e.stopPropagation();
setMenuOpen(false);
setConfirmOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</button>
<div className="flex items-center gap-1.5 self-stretch">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-3.5 w-[18px] items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
'opacity-0 group-hover:opacity-100',
menuOpen && 'opacity-100',
)}
aria-label="Group menu"
onClick={(e) => e.stopPropagation()}
>
<RiMore2Line className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[140px]">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setShowDeleteConfirm(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent showCloseButton={!isDeleting}> <DialogContent className="max-w-md" keyboardAvoid>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Agent Group</DialogTitle> <DialogTitle>Delete agent group</DialogTitle>
<DialogDescription> <DialogDescription>
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 <span className="text-foreground font-medium">{group.name}</span>? This removes all worktrees and sessions in this group.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <Button variant="outline" onClick={() => setConfirmOpen(false)} disabled={isDeleting}>
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
>
Cancel Cancel
</Button> </Button>
<Button <Button variant="destructive" onClick={() => void handleDeleteGroup()} disabled={isDeleting}>
variant="destructive" {isDeleting ? 'Deleting…' : 'Delete'}
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </>
); );
}; };
@@ -6,6 +6,10 @@ import { AgentGroupDetail } from './AgentGroupDetail';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore'; import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore';
import { useMultiRunStore } from '@/stores/useMultiRunStore'; 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'; import type { CreateMultiRunParams } from '@/types/multirun';
interface AgentManagerViewProps { interface AgentManagerViewProps {
@@ -13,6 +17,25 @@ interface AgentManagerViewProps {
} }
export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className }) => { export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ 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<number>(0);
const { const {
selectedGroupName, selectedGroupName,
selectGroup, selectGroup,
@@ -22,6 +45,86 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore(); 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) => { const handleGroupSelect = React.useCallback((groupName: string) => {
selectGroup(groupName); selectGroup(groupName);
}, [selectGroup]); }, [selectGroup]);
@@ -38,10 +141,29 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
if (result) { if (result) {
toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`); toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`);
// Reload groups to pick up the new worktrees and sessions const groupSlug = result.groupSlug;
await loadGroups();
// Select the newly created group const waitForGroup = async (attempts = 6) => {
selectGroup(params.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 50)); 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 { } else {
const error = useMultiRunStore.getState().error; const error = useMultiRunStore.getState().error;
toast.error(error || 'Failed to create agent group'); toast.error(error || 'Failed to create agent group');
+202 -310
View File
@@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client';
import { saveSessionCursor } from '@/lib/messageCursorPersistence'; import { saveSessionCursor } from '@/lib/messageCursorPersistence';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; 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 { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { handleTodoUpdatedEvent } from '@/stores/useTodoStore'; import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
@@ -93,7 +94,9 @@ export const useEventStream = () => {
sessions, sessions,
getWorktreeMetadata, getWorktreeMetadata,
loadMessages, loadMessages,
loadSessions loadSessions,
updateSession,
removeSessionFromStore
} = useSessionStore(); } = useSessionStore();
const { checkConnection } = useConfigStore(); const { checkConnection } = useConfigStore();
@@ -127,6 +130,31 @@ export const useEventStream = () => {
return undefined; return undefined;
}, [activeSessionDirectory, fallbackDirectory]); }, [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 => { const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -250,6 +278,7 @@ export const useEventStream = () => {
const isCleaningUpRef = React.useRef(false); const isCleaningUpRef = React.useRef(false);
const resyncInFlightRef = React.useRef<Promise<void> | null>(null); const resyncInFlightRef = React.useRef<Promise<void> | null>(null);
const lastResyncAtRef = React.useRef(0); const lastResyncAtRef = React.useRef(0);
const permissionToastShownRef = React.useRef<Set<string>>(new Set());
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => { const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
if (typeof document === 'undefined') return 'visible'; if (typeof document === 'undefined') return 'visible';
@@ -265,7 +294,6 @@ export const useEventStream = () => {
const staleCheckIntervalRef = React.useRef<NodeJS.Timeout | null>(null); const staleCheckIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
const lastEventTimestampRef = React.useRef<number>(Date.now()); const lastEventTimestampRef = React.useRef<number>(Date.now());
const isDesktopRuntimeRef = React.useRef<boolean>(false); const isDesktopRuntimeRef = React.useRef<boolean>(false);
const activityStreamAbortControllerRef = React.useRef<AbortController | null>(null);
const maybeBootstrapIfStale = React.useCallback( const maybeBootstrapIfStale = React.useCallback(
(reason: string) => { (reason: string) => {
@@ -299,7 +327,7 @@ export const useEventStream = () => {
}, [currentSessionId]); }, [currentSessionId]);
const requestSessionMetadataRefresh = React.useCallback( const requestSessionMetadataRefresh = React.useCallback(
(sessionId: string | undefined | null) => { (sessionId: string | undefined | null, directoryOverride?: string | null) => {
if (!sessionId) return; if (!sessionId) return;
const now = Date.now(); const now = Date.now();
@@ -310,9 +338,35 @@ export const useEventStream = () => {
timestamps.set(sessionId, now); 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 () => { setTimeout(async () => {
try { 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) { if (session) {
const patch: Partial<Session> = {}; const patch: Partial<Session> = {};
if (typeof session.title === 'string' && session.title.length > 0) { if (typeof session.title === 'string' && session.title.length > 0) {
@@ -330,21 +384,9 @@ export const useEventStream = () => {
} }
}, 100); }, 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 updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => {
const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId); const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
@@ -390,12 +432,28 @@ export const useEventStream = () => {
sessionStatusLastRefreshAtRef.current = now; sessionStatusLastRefreshAtRef.current = now;
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => { const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
const observed = new Set<string>();
const knownSessionIds = new Set(sessions.map((session) => session.id));
Object.entries(statusMap).forEach(([sessionId, raw]) => { Object.entries(statusMap).forEach(([sessionId, raw]) => {
if (!sessionId || !raw) return; if (!sessionId || !raw) return;
observed.add(sessionId);
const phase: 'idle' | 'busy' = const phase: 'idle' | 'busy' =
raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle'; raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle';
updateSessionActivityPhase(sessionId, phase); 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<void> => { const task = (async (): Promise<void> => {
@@ -435,6 +493,19 @@ export const useEventStream = () => {
Object.assign(merged, result.value); 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); applyStatusMap(merged);
} catch { } catch {
// ignored // ignored
@@ -463,92 +534,6 @@ export const useEventStream = () => {
previousSessionDirectoryRef.current = nextDirectory; previousSessionDirectoryRef.current = nextDirectory;
}, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]); }, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]);
const handleActivityEvent = React.useCallback((event: EventData) => {
if (!event?.type) return;
const props = (event.properties ?? {}) as Record<string, unknown>;
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<string, unknown>)
: 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<string, unknown>) : 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) => { const handleEvent = React.useCallback((event: EventData) => {
lastEventTimestampRef.current = Date.now(); lastEventTimestampRef.current = Date.now();
@@ -603,8 +588,7 @@ export const useEventStream = () => {
const phase = typeof props.phase === 'string' ? props.phase : null; const phase = typeof props.phase === 'string' ? props.phase : null;
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) { if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
updateSessionActivityPhase(sessionId, phase); updateSessionActivityPhase(sessionId, phase);
// Refresh session list on activity changes (same trigger as activity indication) requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
requestSessionListRefresh();
} }
break; break;
} }
@@ -621,8 +605,7 @@ export const useEventStream = () => {
sessionId, sessionId,
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle', statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
); );
// Refresh session list on status changes (same trigger as activity indication) requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
requestSessionListRefresh();
} }
} }
break; break;
@@ -1009,11 +992,15 @@ export const useEventStream = () => {
} }
const rawMessageSessionId = (message as { sessionID?: string }).sessionID; const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
const messageSessionId: string = const messageSessionId: string =
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0 typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
? rawMessageSessionId ? rawMessageSessionId
: sessionId; : sessionId;
requestSessionMetadataRefresh(messageSessionId); requestSessionMetadataRefresh(
messageSessionId,
typeof props.directory === 'string' ? props.directory : null,
);
const summaryInfo = message as Message & { summary?: boolean }; const summaryInfo = message as Message & { summary?: boolean };
if (summaryInfo.summary && typeof messageSessionId === 'string') { if (summaryInfo.summary && typeof messageSessionId === 'string') {
@@ -1023,6 +1010,7 @@ export const useEventStream = () => {
break; break;
} }
case 'session.created':
case 'session.updated': { case 'session.updated': {
const candidate = (typeof props.info === 'object' && props.info !== null) ? props.info as Record<string, unknown> : const candidate = (typeof props.info === 'object' && props.info !== null) ? props.info as Record<string, unknown> :
(typeof props.sessionInfo === 'object' && props.sessionInfo !== null) ? props.sessionInfo as Record<string, unknown> : (typeof props.sessionInfo === 'object' && props.sessionInfo !== null) ? props.sessionInfo as Record<string, unknown> :
@@ -1038,6 +1026,32 @@ export const useEventStream = () => {
(typeof props.time === 'object' && props.time !== null) ? props.time as Record<string, unknown> : null; (typeof props.time === 'object' && props.time !== null) ? props.time as Record<string, unknown> : null;
const compactingTimestamp = timeSource && typeof timeSource.compacting === 'number' ? timeSource.compacting as number : null; const compactingTimestamp = timeSource && typeof timeSource.compacting === 'number' ? timeSource.compacting as number : null;
updateSessionCompaction(sessionId, compactingTimestamp); 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<string, unknown>),
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; break;
} }
@@ -1058,56 +1072,65 @@ export const useEventStream = () => {
break; break;
} }
case 'permission.updated': case 'permission.asked': {
if (currentSessionId === props.sessionID) { if (!('sessionID' in props) || typeof props.sessionID !== 'string') {
addPermission(props as unknown as Permission); break;
} }
break;
case 'permission.asked': const request = props as unknown as PermissionRequest;
// 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<string, unknown>;
tool?: {
messageID: string;
callID: string;
};
};
// Convert new permission.asked event format to Permission type addPermission(request);
const permission = {
id: askedProps.id, // Notify if permission is for another session (common with child sessions).
type: askedProps.permission, const toastKey = `${request.sessionID}:${request.id}`;
pattern: askedProps.patterns, // Map patterns to pattern field for compatibility if (!permissionToastShownRef.current.has(toastKey)) {
sessionID: askedProps.sessionID, setTimeout(() => {
messageID: askedProps.tool?.messageID || askedProps.sessionID, const current = currentSessionIdRef.current;
callID: askedProps.tool?.callID, if (current === request.sessionID) {
title: `${askedProps.permission} permission required`, return;
metadata: { }
...askedProps.metadata,
always: askedProps.always, // Store always in metadata for UI access const pending = useSessionStore
patterns: askedProps.patterns, .getState()
}, .permissions
time: { created: Date.now() }, .get(request.sessionID)
} as unknown as Permission; ?.some((entry) => entry.id === request.id);
addPermission(permission);
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; break;
}
case 'permission.replied': case 'permission.replied':
// Permission was responded to - UI will update via permissionStore
break; break;
case 'todo.updated': { case 'todo.updated': {
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
const todos = Array.isArray(props.todos) ? props.todos : []; const todos = Array.isArray(props.todos) ? props.todos : null;
if (sessionId && todos.length > 0) { if (sessionId && todos) {
handleTodoUpdatedEvent( handleTodoUpdatedEvent(
sessionId, sessionId,
todos as Array<{ id: string; content: string; status: string; priority: string }> todos as Array<{ id: string; content: string; status: string; priority: string }>
@@ -1124,12 +1147,13 @@ export const useEventStream = () => {
addPermission, addPermission,
checkConnection, checkConnection,
requestSessionMetadataRefresh, requestSessionMetadataRefresh,
requestSessionListRefresh,
updateSessionCompaction, updateSessionCompaction,
applySessionMetadata, applySessionMetadata,
trackMessage, trackMessage,
reportMessage, reportMessage,
updateSessionActivityPhase, updateSessionActivityPhase,
updateSession,
removeSessionFromStore,
bootstrapState bootstrapState
]); ]);
@@ -1144,7 +1168,6 @@ export const useEventStream = () => {
console.debug('[useEventStream] Connection state:', { console.debug('[useEventStream] Connection state:', {
isDesktopRuntime: isDesktopRuntimeRef.current, isDesktopRuntime: isDesktopRuntimeRef.current,
hasUnsubscribe: Boolean(unsubscribeRef.current), hasUnsubscribe: Boolean(unsubscribeRef.current),
hasActivityStream: Boolean(activityStreamAbortControllerRef.current),
currentSessionId: currentSessionIdRef.current, currentSessionId: currentSessionIdRef.current,
effectiveDirectory, effectiveDirectory,
onlineStatus: onlineStatusRef.current, 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; isCleaningUpRef.current = false;
}, []); }, []);
@@ -1239,9 +1254,9 @@ export const useEventStream = () => {
lastEventTimestampRef.current = Date.now(); lastEventTimestampRef.current = Date.now();
publishStatus('connected', null); publishStatus('connected', null);
checkConnection(); checkConnection();
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some( const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
(phase) => phase === 'busy' (phase) => phase === 'busy' || phase === 'cooldown'
); );
if (hasBusySessions) { if (hasBusySessions) {
void refreshSessionActivityStatus(); void refreshSessionActivityStatus();
@@ -1278,143 +1293,29 @@ export const useEventStream = () => {
} }
try { try {
const sdkUnsub = opencodeClient.subscribeToEvents( const sdkUnsub = opencodeClient.subscribeToGlobalEvents(
handleEvent, (event: RoutedOpencodeEvent) => {
const payload = event.payload as unknown as EventData;
const payloadRecord = event.payload as unknown as Record<string, unknown>;
const baseProperties =
typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null
? (payloadRecord.properties as Record<string, unknown>)
: {};
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, onError,
onOpen, 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<string, unknown>;
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<string, unknown>;
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 = () => { const compositeUnsub = () => {
try { try {
@@ -1428,10 +1329,6 @@ export const useEventStream = () => {
unsubscribeRef.current = compositeUnsub; unsubscribeRef.current = compositeUnsub;
} else { } else {
compositeUnsub(); compositeUnsub();
if (activityStreamAbortControllerRef.current) {
activityStreamAbortControllerRef.current.abort();
activityStreamAbortControllerRef.current = null;
}
} }
} catch (subscriptionError) { } catch (subscriptionError) {
console.error('[useEventStream] Error during subscription:', subscriptionError); console.error('[useEventStream] Error during subscription:', subscriptionError);
@@ -1445,7 +1342,6 @@ export const useEventStream = () => {
resyncMessages, resyncMessages,
requestSessionMetadataRefresh, requestSessionMetadataRefresh,
handleEvent, handleEvent,
handleActivityEvent,
effectiveDirectory, effectiveDirectory,
refreshSessionActivityStatus, refreshSessionActivityStatus,
waitForDesktopBridge, waitForDesktopBridge,
@@ -1499,8 +1395,7 @@ export const useEventStream = () => {
const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null; const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null;
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) { if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
updateSessionActivityPhase(sessionId, phase); updateSessionActivityPhase(sessionId, phase);
// Refresh session list on activity changes (same trigger as activity indication) requestSessionMetadataRefresh(sessionId);
requestSessionListRefresh();
} }
}; };
window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener); window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
@@ -1543,10 +1438,9 @@ export const useEventStream = () => {
resyncMessages(sessionId, 'visibility_restore').catch(() => {}); resyncMessages(sessionId, 'visibility_restore').catch(() => {});
requestSessionMetadataRefresh(sessionId); requestSessionMetadataRefresh(sessionId);
} }
void loadSessions(); void refreshSessionActivityStatus();
void refreshSessionActivityStatus(); publishStatus('connecting', 'Resuming stream');
publishStatus('connecting', 'Resuming stream');
startStream({ resetAttempts: true }); startStream({ resetAttempts: true });
} }
} else { } else {
@@ -1571,7 +1465,6 @@ export const useEventStream = () => {
.then(() => console.info('[useEventStream] Messages refreshed on focus')) .then(() => console.info('[useEventStream] Messages refreshed on focus'))
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err)); .catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
} }
void loadSessions();
void refreshSessionActivityStatus(); void refreshSessionActivityStatus();
publishStatus('connecting', 'Resuming stream'); publishStatus('connecting', 'Resuming stream');
@@ -1619,7 +1512,7 @@ export const useEventStream = () => {
const now = Date.now(); const now = Date.now();
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some( const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
(phase) => phase === 'busy' (phase) => phase === 'busy' || phase === 'cooldown'
); );
if (hasBusySessions) { if (hasBusySessions) {
void refreshSessionActivityStatus(); void refreshSessionActivityStatus();
@@ -1692,7 +1585,6 @@ export const useEventStream = () => {
scheduleReconnect, scheduleReconnect,
loadMessages, loadMessages,
requestSessionMetadataRefresh, requestSessionMetadataRefresh,
requestSessionListRefresh,
updateSessionActivityPhase, updateSessionActivityPhase,
refreshSessionActivityStatus, refreshSessionActivityStatus,
shouldHoldConnection, shouldHoldConnection,
+1 -1
View File
@@ -8,7 +8,7 @@ export const useFileSystemAccess = () => {
setIsDesktop(isDesktopRuntime()); 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) { if (!isDesktop) {
return { success: true, path: directoryPath }; return { success: true, path: directoryPath };
} }
+29 -12
View File
@@ -2,11 +2,12 @@ import React from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { isDesktopRuntime } from '@/lib/desktop'; import { isDesktopRuntime } from '@/lib/desktop';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
const MENU_ACTION_EVENT = 'openchamber:menu-action'; const MENU_ACTION_EVENT = 'openchamber:menu-action';
@@ -42,20 +43,36 @@ export const useMenuActions = (
setSettingsDialogOpen, setSettingsDialogOpen,
setAboutDialogOpen, setAboutDialogOpen,
} = useUIStore(); } = useUIStore();
const { setDirectory } = useDirectoryStore(); const { addProject } = useProjectsStore();
const { requestAccess, startAccessing } = useFileSystemAccess();
const { setThemeMode } = useThemeSystem(); const { setThemeMode } = useThemeSystem();
const isDownloadingLogsRef = React.useRef(false); const isDownloadingLogsRef = React.useRef(false);
const handleChangeWorkspace = React.useCallback(() => { const handleChangeWorkspace = React.useCallback(() => {
if (isDesktopRuntime() && window.opencodeDesktop?.requestDirectoryAccess) { if (isDesktopRuntime()) {
window.opencodeDesktop requestAccess('')
.requestDirectoryAccess('') .then(async (result) => {
.then((result) => { if (!result.success || !result.path) {
if (result.success && result.path) { if (result.error && result.error !== 'Directory selection cancelled') {
setDirectory(result.path, { showOverlay: true }); toast.error('Failed to select directory', {
} else if (result.error && result.error !== 'Directory selection cancelled') { description: result.error,
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 { } else {
sessionEvents.requestDirectoryDialog(); sessionEvents.requestDirectoryDialog();
} }
}, [setDirectory]); }, [addProject, requestAccess, startAccessing]);
React.useEffect(() => { React.useEffect(() => {
const handleMenuAction = (event: Event) => { const handleMenuAction = (event: Event) => {
+2
View File
@@ -407,6 +407,7 @@ svg.animate-spin {
--sidebar-accent-foreground: oklch(0.25 0.02 40); /* Dark warm text */ --sidebar-accent-foreground: oklch(0.25 0.02 40); /* Dark warm text */
--sidebar-border: oklch(0.85 0.02 70); /* Warm border */ --sidebar-border: oklch(0.85 0.02 70); /* Warm border */
--sidebar-ring: oklch(0.65 0.2 55); /* Focus ring */ --sidebar-ring: oklch(0.65 0.2 55); /* Focus ring */
--sidebar-stuck-bg: #DDDDD3; /* Desktop sidebar sticky header background */
} }
.dark { .dark {
@@ -443,6 +444,7 @@ svg.animate-spin {
--sidebar-accent-foreground: oklch(0.85 0.02 90); /* #cdccc3 */ --sidebar-accent-foreground: oklch(0.85 0.02 90); /* #cdccc3 */
--sidebar-border: oklch(0.31 0.01 35); /* #393836 */ --sidebar-border: oklch(0.31 0.01 35); /* #393836 */
--sidebar-ring: oklch(0.77 0.17 85); /* #edb449 */ --sidebar-ring: oklch(0.77 0.17 85); /* #edb449 */
--sidebar-stuck-bg: #1F1F1D; /* Desktop sidebar sticky header background */
} }
* { * {
+10
View File
@@ -322,6 +322,14 @@ export interface FilesAPI {
createDirectory(path: string): Promise<{ success: boolean; path: string }>; createDirectory(path: string): Promise<{ success: boolean; path: string }>;
} }
export interface ProjectEntry {
id: string;
path: string;
label?: string;
addedAt?: number;
lastOpenedAt?: number;
}
export interface SettingsPayload { export interface SettingsPayload {
themeId?: string; themeId?: string;
useSystemTheme?: boolean; useSystemTheme?: boolean;
@@ -330,6 +338,8 @@ export interface SettingsPayload {
darkThemeId?: string; darkThemeId?: string;
lastDirectory?: string; lastDirectory?: string;
homeDirectory?: string; homeDirectory?: string;
projects?: ProjectEntry[];
activeProjectId?: string;
approvedDirectories?: string[]; approvedDirectories?: string[];
securityScopedBookmarks?: string[]; securityScopedBookmarks?: string[];
pinnedDirectories?: string[]; pinnedDirectories?: string[];
+6 -2
View File
@@ -1,3 +1,5 @@
import type { ProjectEntry } from '@/lib/api/types';
export type AssistantNotificationPayload = { export type AssistantNotificationPayload = {
title?: string; title?: string;
body?: string; body?: string;
@@ -43,6 +45,8 @@ export type DesktopSettings = {
darkThemeId?: string; darkThemeId?: string;
lastDirectory?: string; lastDirectory?: string;
homeDirectory?: string; homeDirectory?: string;
projects?: ProjectEntry[];
activeProjectId?: string;
approvedDirectories?: string[]; approvedDirectories?: string[];
securityScopedBookmarks?: string[]; securityScopedBookmarks?: string[];
pinnedDirectories?: string[]; pinnedDirectories?: string[];
@@ -72,7 +76,7 @@ export type DesktopApi = {
getHomeDirectory?: () => Promise<{ success: boolean; path: string | null }>; getHomeDirectory?: () => Promise<{ success: boolean; path: string | null }>;
getSettings?: () => Promise<DesktopSettings>; getSettings?: () => Promise<DesktopSettings>;
updateSettings?: (changes: Partial<DesktopSettings>) => Promise<DesktopSettings>; updateSettings?: (changes: Partial<DesktopSettings>) => Promise<DesktopSettings>;
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 }>; startAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
stopAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>; stopAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
notifyAssistantCompletion?: (payload?: AssistantNotificationPayload) => Promise<{ success: boolean }>; notifyAssistantCompletion?: (payload?: AssistantNotificationPayload) => Promise<{ success: boolean }>;
@@ -205,7 +209,7 @@ export const updateDesktopSettings = async (
export const requestDirectoryAccess = async ( export const requestDirectoryAccess = async (
directoryPath: string directoryPath: string
): Promise<{ success: boolean; path?: string; error?: string }> => { ): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
const api = getDesktopApi(); const api = getDesktopApi();
if (!api || !api.requestDirectoryAccess) { if (!api || !api.requestDirectoryAccess) {
return { success: true, path: directoryPath }; return { success: true, path: directoryPath };
+14
View File
@@ -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;
};
+452 -25
View File
@@ -13,6 +13,7 @@ import type {
FilePartInput, FilePartInput,
Event, Event,
} from "@opencode-ai/sdk/v2"; } from "@opencode-ai/sdk/v2";
import type { PermissionRequest } from "@/types/permission";
type StreamEvent<TData> = { type StreamEvent<TData> = {
data: TData; data: TData;
event?: string; event?: string;
@@ -20,6 +21,11 @@ type StreamEvent<TData> = {
retry?: number; retry?: number;
}; };
export type RoutedOpencodeEvent = {
directory: string;
payload: Event;
};
// Use relative path by default (works with both dev and nginx proxy server) // 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 // Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api"; const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api";
@@ -132,6 +138,14 @@ class OpencodeService {
private sseAbortControllers: Map<string, AbortController> = new Map(); private sseAbortControllers: Map<string, AbortController> = new Map();
private currentDirectory: string | undefined = undefined; private currentDirectory: string | undefined = undefined;
private globalSseAbortController: AbortController | null = null;
private globalSseTask: Promise<void> | 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) { constructor(baseUrl: string = DEFAULT_BASE_URL) {
const desktopBase = resolveDesktopBaseUrl(); const desktopBase = resolveDesktopBaseUrl();
const requestedBaseUrl = desktopBase || baseUrl; const requestedBaseUrl = desktopBase || baseUrl;
@@ -726,21 +740,46 @@ class OpencodeService {
return this.getSessionStatusForDirectory(null); return this.getSessionStatusForDirectory(null);
} }
// Tools
async listToolIds(options?: { directory?: string | null }): Promise<string[]> {
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 // Permissions
async respondToPermission( async replyToPermission(
sessionId: string, requestId: string,
permissionId: string, reply: 'once' | 'always' | 'reject',
response: 'once' | 'always' | 'reject' options?: { message?: string }
): Promise<boolean> { ): Promise<boolean> {
const result = await this.client.permission.respond({ const result = await this.client.permission.reply({
sessionID: sessionId, requestID: requestId,
permissionID: permissionId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}), ...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
response reply,
...(options?.message ? { message: options.message } : {}),
}); });
return result.data || false; return result.data || false;
} }
async listPendingPermissions(): Promise<PermissionRequest[]> {
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 // Configuration
async getConfig(): Promise<Config> { async getConfig(): Promise<Config> {
const response = await this.client.config.get(); const response = await this.client.config.get();
@@ -773,7 +812,7 @@ class OpencodeService {
/** /**
* Update config with a partial modification function. * 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. * NOTE: This method is deprecated for agent configuration.
* Use backend endpoints at /api/config/agents/* instead, which write directly to files. * 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<string, unknown>;
const directoryCandidate =
typeof record.directory === 'string'
? record.directory
: typeof record.properties === 'object' && record.properties !== null
? ((record.properties as Record<string, unknown>).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<string, unknown>;
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<void> {
const globalEndpoint = `${this.baseUrl.replace(/\/+$/, '')}/global/event`;
let attempt = 0;
while (!abortController.signal.aborted) {
try {
const headers: Record<string, string> = {
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 // Event Streaming using SDK SSE (Server-Sent Events) with AsyncGenerator
subscribeToEvents( subscribeToEvents(
onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void, onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void,
@@ -839,6 +1158,7 @@ class OpencodeService {
options?: { scope?: 'global' | 'directory'; key?: string } options?: { scope?: 'global' | 'directory'; key?: string }
): () => void { ): () => void {
const subscriptionKey = options?.key ?? 'default'; const subscriptionKey = options?.key ?? 'default';
const scope = options?.scope ?? 'directory';
const existingController = this.sseAbortControllers.get(subscriptionKey); const existingController = this.sseAbortControllers.get(subscriptionKey);
if (existingController) { if (existingController) {
existingController.abort(); existingController.abort();
@@ -850,17 +1170,122 @@ class OpencodeService {
let lastEventId: string | undefined; 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<string, unknown>;
const existingProperties =
typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null
? (payloadRecord.properties as Record<string, unknown>)
: {};
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<string, unknown>;
if (typeof record.type === 'string') {
return record as Event;
}
const nestedPayload = record.payload;
if (nestedPayload && typeof nestedPayload === 'object') {
const nestedRecord = nestedPayload as Record<string, unknown>;
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<string, unknown>)
: null;
const properties = {
...(existingProperties ?? {}),
directory: record.directory,
};
return { ...nestedRecord, properties } as Event;
}
return nestedRecord as Event;
}
}
return null;
};
console.log('[OpencodeClient] Starting SSE subscription...'); console.log('[OpencodeClient] Starting SSE subscription...');
// Start async generator in background with reconnect on failure // Start async generator in background with reconnect on failure
(async () => { (async () => {
const resolvedDirectory = const resolvedDirectory =
options?.scope === 'global' typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
? undefined ? directoryOverride.trim()
: typeof directoryOverride === 'string' && directoryOverride.trim().length > 0 : this.currentDirectory;
? directoryOverride.trim()
: this.currentDirectory; console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'default');
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'global');
const connect = async (attempt: number): Promise<void> => { const connect = async (attempt: number): Promise<void> => {
try { try {
@@ -891,8 +1316,9 @@ class OpencodeService {
lastEventId = event.id; lastEventId = event.id;
} }
const payload = event.data; const payload = event.data;
if (payload && typeof payload === 'object') { const normalized = normalizeEventPayload(payload);
onMessage(payload as Event); if (normalized) {
onMessage(normalized);
} }
}, },
}; };
@@ -915,13 +1341,6 @@ class OpencodeService {
break; 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) { } catch (error: unknown) {
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
console.log('[OpencodeClient] SSE stream aborted normally'); console.log('[OpencodeClient] SSE stream aborted normally');
@@ -936,6 +1355,13 @@ class OpencodeService {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
await connect(attempt + 1); 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(); abortController.abort();
}; };
} }
// File Operations // File Operations
@@ -1092,7 +1519,7 @@ class OpencodeService {
const healthData = await response.json(); 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) { if (healthData.isOpenCodeReady === false) {
return false; return false;
} }
+68
View File
@@ -32,6 +32,16 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
localStorage.setItem('homeDirectory', settings.homeDirectory); localStorage.setItem('homeDirectory', settings.homeDirectory);
window.__OPENCHAMBER_HOME__ = 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) { if (Array.isArray(settings.pinnedDirectories) && settings.pinnedDirectories.length > 0) {
localStorage.setItem('pinnedDirectories', JSON.stringify(settings.pinnedDirectories)); localStorage.setItem('pinnedDirectories', JSON.stringify(settings.pinnedDirectories));
} else { } else {
@@ -78,6 +88,55 @@ const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs']
return result; return result;
}; };
const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: NonNullable<DesktopSettings['projects']> = [];
const seenIds = new Set<string>();
const seenPaths = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
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<DesktopSettings['projects']>[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 getPersistApi = (): PersistApi | undefined => {
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist; const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
if (candidate && typeof candidate === 'object') { if (candidate && typeof candidate === 'object') {
@@ -138,6 +197,15 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
result.homeDirectory = candidate.homeDirectory; 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)) { if (Array.isArray(candidate.approvedDirectories)) {
result.approvedDirectories = candidate.approvedDirectories.filter( result.approvedDirectories = candidate.approvedDirectories.filter(
(entry): entry is string => typeof entry === 'string' && entry.length > 0 (entry): entry is string => typeof entry === 'string' && entry.length > 0
+1
View File
@@ -11,6 +11,7 @@ export type SessionDeleteRequest = {
export type SessionCreateRequest = { export type SessionCreateRequest = {
worktreeMode?: 'main' | 'create' | 'reuse'; worktreeMode?: 'main' | 'create' | 'reuse';
parentID?: string | null; parentID?: string | null;
projectId?: string | null;
}; };
type DeleteListener = (request: SessionDeleteRequest) => void; type DeleteListener = (request: SessionDeleteRequest) => void;
+2 -10
View File
@@ -280,16 +280,8 @@ const resolveSessionDirectory = async (sessionId: string | null | undefined): Pr
try { try {
const sessionStore = useSessionStore.getState(); const sessionStore = useSessionStore.getState();
const metadata = sessionStore.getWorktreeMetadata(sessionId); const directory = sessionStore.getDirectoryForSession(sessionId);
if (metadata?.path) { return directory ?? undefined;
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;
} catch (error) { } catch (error) {
console.warn('Failed to resolve session directory override:', error); console.warn('Failed to resolve session directory override:', error);
return undefined; return undefined;
+18 -21
View File
@@ -1,19 +1,19 @@
import { create } from "zustand"; import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware"; import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client"; 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 { isEditPermissionType, getAgentDefaultEditPermission } from "./utils/permissionUtils";
import { getSafeStorage } from "./utils/safeStorage"; import { getSafeStorage } from "./utils/safeStorage";
import { useMessageStore } from "./messageStore"; import { useMessageStore } from "./messageStore";
import { useSessionStore } from "./sessionStore"; import { useSessionStore } from "./sessionStore";
interface PermissionState { interface PermissionState {
permissions: Map<string, Permission[]>; permissions: Map<string, PermissionRequest[]>;
} }
interface PermissionActions { interface PermissionActions {
addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void; addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void;
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise<void>; respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
} }
type PermissionStore = PermissionState & PermissionActions; type PermissionStore = PermissionState & PermissionActions;
@@ -21,11 +21,11 @@ type PermissionStore = PermissionState & PermissionActions;
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null; typeof value === "object" && value !== null;
const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]> => { const sanitizePermissionEntries = (value: unknown): Array<[string, PermissionRequest[]]> => {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return []; return [];
} }
const entries: Array<[string, Permission[]]> = []; const entries: Array<[string, PermissionRequest[]]> = [];
value.forEach((entry) => { value.forEach((entry) => {
if (!Array.isArray(entry) || entry.length !== 2) { if (!Array.isArray(entry) || entry.length !== 2) {
return; return;
@@ -34,7 +34,7 @@ const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]
if (typeof sessionId !== "string" || !Array.isArray(permissions)) { if (typeof sessionId !== "string" || !Array.isArray(permissions)) {
return; return;
} }
entries.push([sessionId, permissions as Permission[]]); entries.push([sessionId, permissions as PermissionRequest[]]);
}); });
return entries; return entries;
}; };
@@ -42,15 +42,7 @@ const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]
const executeWithPermissionDirectory = async <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => { const executeWithPermissionDirectory = async <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => {
try { try {
const sessionStore = useSessionStore.getState(); const sessionStore = useSessionStore.getState();
const metadata = sessionStore.getWorktreeMetadata(sessionId); const directory = sessionStore.getDirectoryForSession(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;
if (directory) { if (directory) {
return opencodeClient.withDirectory(directory, operation); return opencodeClient.withDirectory(directory, operation);
} }
@@ -67,13 +59,18 @@ export const usePermissionStore = create<PermissionStore>()(
permissions: new Map(), permissions: new Map(),
addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => { addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => {
const sessionId = permission.sessionID; const sessionId = permission.sessionID;
if (!sessionId) { if (!sessionId) {
return; 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); let agentName = contextData?.currentAgentContext?.get(sessionId);
if (!agentName) { if (!agentName) {
@@ -109,8 +106,8 @@ export const usePermissionStore = create<PermissionStore>()(
}); });
}, },
respondToPermission: async (sessionId: string, permissionId: string, response: PermissionResponse) => { respondToPermission: async (sessionId: string, requestId: string, response: PermissionResponse) => {
await executeWithPermissionDirectory(sessionId, () => opencodeClient.respondToPermission(sessionId, permissionId, response)); await executeWithPermissionDirectory(sessionId, () => opencodeClient.replyToPermission(requestId, response));
if (response === 'reject') { if (response === 'reject') {
const messageStore = useMessageStore.getState(); const messageStore = useMessageStore.getState();
@@ -120,7 +117,7 @@ export const usePermissionStore = create<PermissionStore>()(
set((state) => { set((state) => {
const sessionPermissions = state.permissions.get(sessionId) || []; 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); const newPermissions = new Map(state.permissions);
newPermissions.set(sessionId, updatedPermissions); newPermissions.set(sessionId, updatedPermissions);
return { permissions: newPermissions }; return { permissions: newPermissions };
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,5 +1,5 @@
import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; 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 { export interface AttachedFile {
id: string; id: string;
@@ -70,13 +70,14 @@ export type NewSessionDraftState = {
export interface SessionStore { export interface SessionStore {
sessions: Session[]; sessions: Session[];
sessionsByDirectory: Map<string, Session[]>;
currentSessionId: string | null; currentSessionId: string | null;
lastLoadedDirectory: string | null; lastLoadedDirectory: string | null;
messages: Map<string, { info: Message; parts: Part[] }[]>; messages: Map<string, { info: Message; parts: Part[] }[]>;
sessionMemoryState: Map<string, SessionMemoryState>; sessionMemoryState: Map<string, SessionMemoryState>;
messageStreamStates: Map<string, MessageStreamLifecycle>; messageStreamStates: Map<string, MessageStreamLifecycle>;
sessionCompactionUntil: Map<string, number>; sessionCompactionUntil: Map<string, number>;
permissions: Map<string, Permission[]>; permissions: Map<string, PermissionRequest[]>;
sessionAbortFlags: Map<string, { timestamp: number; acknowledged: boolean }>; sessionAbortFlags: Map<string, { timestamp: number; acknowledged: boolean }>;
attachedFiles: AttachedFile[]; attachedFiles: AttachedFile[];
abortPromptSessionId: string | null; abortPromptSessionId: string | null;
@@ -96,6 +97,7 @@ export interface SessionStore {
webUICreatedSessions: Set<string>; webUICreatedSessions: Set<string>;
worktreeMetadata: Map<string, import('@/types/worktree').WorktreeMetadata>; worktreeMetadata: Map<string, import('@/types/worktree').WorktreeMetadata>;
availableWorktrees: import('@/types/worktree').WorktreeMetadata[]; availableWorktrees: import('@/types/worktree').WorktreeMetadata[];
availableWorktreesByProject: Map<string, import('@/types/worktree').WorktreeMetadata[]>;
currentAgentContext: Map<string, string>; currentAgentContext: Map<string, string>;
@@ -139,10 +141,11 @@ export interface SessionStore {
markMessageStreamSettled: (messageId: string) => void; markMessageStreamSettled: (messageId: string) => void;
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Message) => void; updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Message) => void;
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void; updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void;
addPermission: (permission: Permission) => void; addPermission: (permission: PermissionRequest) => void;
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise<void>; respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
clearError: () => void; clearError: () => void;
getSessionsByDirectory: (directory: string) => Session[]; getSessionsByDirectory: (directory: string) => Session[];
getDirectoryForSession: (sessionId: string) => string | null;
getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null; getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null;
getCurrentAgent: (sessionId: string) => string | undefined; getCurrentAgent: (sessionId: string) => string | undefined;
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void; syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void;
@@ -190,6 +193,7 @@ export interface SessionStore {
pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => void; pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => void;
updateSession: (session: Session) => void; updateSession: (session: Session) => void;
removeSessionFromStore: (sessionId: string) => void;
revertToMessage: (sessionId: string, messageId: string) => Promise<void>; revertToMessage: (sessionId: string, messageId: string) => Promise<void>;
handleSlashUndo: (sessionId: string) => Promise<void>; handleSlashUndo: (sessionId: string) => Promise<void>;
+520 -82
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { devtools } from 'zustand/middleware'; import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryStore } from './useDirectoryStore'; import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
import { useSessionStore } from './useSessionStore'; import { useSessionStore } from './useSessionStore';
import type { WorktreeMetadata } from '@/types/worktree'; import type { WorktreeMetadata } from '@/types/worktree';
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService'; import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
@@ -9,6 +10,28 @@ import type { Session } from '@opencode-ai/sdk/v2';
const OPENCHAMBER_DIR = '.openchamber'; 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. * Agent group session parsed from OpenCode session titles.
* Session titles follow pattern: `groupSlug/provider/model` or `groupSlug/provider/model/index` * Session titles follow pattern: `groupSlug/provider/model` or `groupSlug/provider/model/index`
@@ -69,12 +92,16 @@ interface AgentGroupsActions {
selectGroup: (groupName: string | null) => void; selectGroup: (groupName: string | null) => void;
/** Select a session within the current group */ /** Select a session within the current group */
selectSession: (sessionId: string | null) => void; selectSession: (sessionId: string | null) => void;
/** Delete the entire group (all worktrees + sessions in those worktrees). */
deleteGroup: (groupName: string) => Promise<boolean>;
/** Delete a single worktree within a group (and all sessions in that worktree). */
deleteGroupWorktree: (groupName: string, worktreePath: string) => Promise<boolean>;
/** Keep one worktree and remove all others in the group. */
keepOnlyGroupWorktree: (groupName: string, keepWorktreePath: string) => Promise<boolean>;
/** Get the currently selected group */ /** Get the currently selected group */
getSelectedGroup: () => AgentGroup | null; getSelectedGroup: () => AgentGroup | null;
/** Get the currently selected session */ /** Get the currently selected session */
getSelectedSession: () => AgentGroupSession | null; getSelectedSession: () => AgentGroupSession | null;
/** Delete a group and all its sessions, archiving worktrees */
deleteGroup: (groupName: string) => Promise<{ success: boolean; deletedCount: number; failedCount: number }>;
/** Clear error */ /** Clear error */
clearError: () => void; clearError: () => void;
} }
@@ -88,6 +115,214 @@ const normalize = (value: string): string => {
return replaced.replace(/\/+$/, ''); 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<string[]> => {
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<typeof opencodeClient.getApiClient>,
directory: string
): Promise<string> => {
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<typeof opencodeClient.getApiClient>,
directory: string
): Promise<Session[]> => {
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<Map<string, WorktreeMetadata>> => {
const map = new Map<string, WorktreeMetadata>();
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<typeof opencodeClient.getApiClient>;
group: AgentGroup;
projectDirectory: string;
worktreePaths: string[];
}): Promise<Array<{ worktreePath: string; sessionIds: string[]; metadata?: WorktreeMetadata }>> => {
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. * Parse a session title to extract group, provider, model, and index.
* Title format: groupSlug/provider/model[/index] * Title format: groupSlug/provider/model[/index]
@@ -158,31 +393,29 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
loadGroups: async () => { loadGroups: async () => {
const currentDirectory = useDirectoryStore.getState().currentDirectory; const currentDirectory = useDirectoryStore.getState().currentDirectory;
if (!currentDirectory) { const projectDirectory = resolveProjectDirectory(currentDirectory);
if (!projectDirectory) {
set({ groups: [], isLoading: false, error: 'No project directory selected' }); set({ groups: [], isLoading: false, error: 'No project directory selected' });
return; return;
} }
// Check if we're inside a .openchamber worktree - if so, don't reload const normalizedProject = normalize(projectDirectory);
// This prevents groups from disappearing when switching to a worktree session const openChamberRoot = buildOpenChamberRoot(normalizedProject);
const normalizedCurrent = normalize(currentDirectory);
if (normalizedCurrent.includes(`/${OPENCHAMBER_DIR}/`)) {
// We're inside a worktree, don't reload groups
set({ isLoading: false });
return;
}
const previousGroups = get().groups; const previousGroups = get().groups;
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const apiClient = opencodeClient.getApiClient(); 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 // Get git worktree info first - we need to query each worktree separately
let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>(); let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>();
let worktreeInfoList: Awaited<ReturnType<typeof listWorktrees>> = []; let worktreeInfoList: Awaited<ReturnType<typeof listWorktrees>> = [];
try { try {
worktreeInfoList = await listWorktrees(normalizedCurrent); worktreeInfoList = await listWorktrees(normalizedProject);
worktreeInfoMap = new Map( worktreeInfoMap = new Map(
worktreeInfoList.map((info) => [normalize(info.worktree), info]) worktreeInfoList.map((info) => [normalize(info.worktree), info])
); );
@@ -190,32 +423,90 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
console.debug('Failed to list git worktrees'); console.debug('Failed to list git worktrees');
} }
// Fetch sessions from each worktree directory (sessions are stored per-directory in OpenCode) const fetchCandidateSessions = async (): Promise<Session[]> => {
// Filter to only .openchamber worktrees (agent group worktrees) try {
const openchamberWorktrees = worktreeInfoList.filter( const scoped = await apiClient.session.list({ directory: normalizedProject });
(info) => normalize(info.worktree).includes(`/${OPENCHAMBER_DIR}/`) const list = Array.isArray(scoped.data) ? scoped.data : [];
); if (list.some((session) => {
const dir = normalize((session as { directory?: string | null }).directory ?? '');
const sessionsMap = new Map<string, Session>(); return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical);
})) {
// Fetch sessions from each openchamber worktree return list;
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);
} }
}) } catch {
); // ignore and fall back to global list
}
const allSessions = Array.from(sessionsMap.values());
const global = await apiClient.session.list(undefined);
return Array.isArray(global.data) ? global.data : [];
};
const fetchSessionsByWorktreeDirectories = async (directories: string[]): Promise<Session[]> => {
const sessionsMap = new Map<string, Session>();
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<string>();
// 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<string, number>();
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 // Parse sessions and group by groupSlug
const groupsMap = new Map<string, AgentGroupSession[]>(); const groupsMap = new Map<string, AgentGroupSession[]>();
@@ -236,7 +527,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
branch: worktreeInfo?.branch ?? '', branch: worktreeInfo?.branch ?? '',
displayLabel: `${parsed.provider}/${parsed.model}`, displayLabel: `${parsed.provider}/${parsed.model}`,
worktreeMetadata: worktreeInfo worktreeMetadata: worktreeInfo
? mapWorktreeToMetadata(normalizedCurrent, worktreeInfo) ? mapWorktreeToMetadata(normalizedProject, worktreeInfo)
: undefined, : undefined,
}; };
@@ -253,9 +544,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
([name, sessions]) => { ([name, sessions]) => {
// Find the most recent session update time for lastActive // Find the most recent session update time for lastActive
const lastActive = sessions.reduce((max, s) => { const lastActive = sessions.reduce((max, s) => {
// Find the original session to get the time const updatedTime = sessionUpdatedAtById.get(s.id) ?? 0;
const originalSession = allSessions.find((os) => os.id === s.id);
const updatedTime = originalSession?.time?.updated ?? 0;
return Math.max(max, updatedTime); return Math.max(max, updatedTime);
}, 0); }, 0);
@@ -305,6 +594,196 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
set({ selectedSessionId: sessionId }); 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<string>();
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<string>();
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<string>();
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: () => { getSelectedGroup: () => {
const { groups, selectedGroupName } = get(); const { groups, selectedGroupName } = get();
if (!selectedGroupName) return null; if (!selectedGroupName) return null;
@@ -321,47 +800,6 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
clearError: () => { clearError: () => {
set({ error: null }); 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' } { name: 'agent-groups-store' }
) )
+213 -87
View File
@@ -1,9 +1,9 @@
import { create } from "zustand"; import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware"; 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 { opencodeClient } from "@/lib/opencode/client";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync";
import { import {
startConfigUpdate, startConfigUpdate,
finishConfigUpdate, finishConfigUpdate,
@@ -11,13 +11,20 @@ import {
} from "@/lib/configUpdate"; } from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage"; import { getSafeStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore"; 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 // Note: useDirectoryStore cannot be imported at top level to avoid circular dependency
// useDirectoryStore -> useAgentsStore (for refreshAfterOpenCodeRestart) // useDirectoryStore -> useAgentsStore (for refreshAfterOpenCodeRestart)
// useAgentsStore -> useDirectoryStore (for currentDirectory) // useAgentsStore -> useDirectoryStore (for currentDirectory)
// Instead we access it from the window object where it's exposed
const getCurrentDirectory = (): string | null => { 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 { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__; const store = (window as any).__zustand_directory_store__;
@@ -27,6 +34,29 @@ const getCurrentDirectory = (): string | null => {
} catch { } catch {
// ignore // 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; return null;
}; };
@@ -40,15 +70,7 @@ export interface AgentConfig {
top_p?: number; top_p?: number;
prompt?: string; prompt?: string;
mode?: "primary" | "subagent" | "all"; mode?: "primary" | "subagent" | "all";
tools?: Record<string, boolean>; permission?: PermissionConfig | null;
permission?: {
edit?: "allow" | "ask" | "deny";
bash?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
skill?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
webfetch?: "allow" | "ask" | "deny";
doom_loop?: "allow" | "ask" | "deny";
external_directory?: "allow" | "ask" | "deny";
};
disable?: boolean; disable?: boolean;
scope?: AgentScope; scope?: AgentScope;
@@ -96,15 +118,7 @@ export interface AgentDraft {
top_p?: number; top_p?: number;
prompt?: string; prompt?: string;
mode?: "primary" | "subagent" | "all"; mode?: "primary" | "subagent" | "all";
tools?: Record<string, boolean>; permission?: PermissionConfig;
permission?: {
edit?: "allow" | "ask" | "deny";
bash?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
skill?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
webfetch?: "allow" | "ask" | "deny";
doom_loop?: "allow" | "ask" | "deny";
external_directory?: "allow" | "ask" | "deny";
};
disable?: boolean; disable?: boolean;
} }
@@ -153,44 +167,67 @@ export const useAgentsStore = create<AgentsStore>()(
loadAgents: async () => { loadAgents: async () => {
set({ isLoading: true }); set({ isLoading: true });
const previousAgents = get().agents; const previousAgents = get().agents;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
try { try {
const agents = await opencodeClient.listAgents(); const configDirectory = getConfigDirectory();
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
// Fetch scope info for each agent
const currentDirectory = getCurrentDirectory(); // Ensure we list agents using the correct project context
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const agents = await opencodeClient.withDirectory(configDirectory, () => opencodeClient.listAgents());
const agentsWithScope = await Promise.all( const agentsWithScope = await Promise.all(
agents.map(async (agent) => { agents.map(async (agent) => {
try { 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) { if (response.ok) {
const data = await response.json(); 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; // Prioritize explicit scope from server response
return { ...agent, scope: scope as AgentScope | undefined }; 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 { } catch (err) {
// Ignore scope fetch errors and fall back to agent defaults console.warn(`[AgentsStore] Failed to fetch config for agent ${agent.name}:`, err);
} }
return agent; 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; return true;
} catch (error) { } catch {
lastError = error; // ignore error
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
} }
} }
console.error("Failed to load agents:", lastError); set({ isLoading: false });
set({ agents: previousAgents, isLoading: false });
return false; return false;
}, },
@@ -198,8 +235,10 @@ export const useAgentsStore = create<AgentsStore>()(
startConfigUpdate("Creating agent configuration…"); startConfigUpdate("Creating agent configuration…");
let requiresReload = false; let requiresReload = false;
try { try {
console.log('[AgentsStore] Creating agent:', config.name);
const agentConfig: Record<string, unknown> = { const agentConfig: Record<string, unknown> = {
mode: config.mode || "subagent", mode: config.mode || 'subagent',
}; };
if (config.description) agentConfig.description = config.description; if (config.description) agentConfig.description = config.description;
@@ -207,18 +246,21 @@ export const useAgentsStore = create<AgentsStore>()(
if (config.temperature !== undefined) agentConfig.temperature = config.temperature; if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
if (config.top_p !== undefined) agentConfig.top_p = config.top_p; if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
if (config.prompt) agentConfig.prompt = config.prompt; 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.permission) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable; if (config.disable !== undefined) agentConfig.disable = config.disable;
if (config.scope) agentConfig.scope = config.scope; if (config.scope) agentConfig.scope = config.scope;
// Get current directory for project-level agent support console.log('[AgentsStore] Agent config to save:', agentConfig);
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const configDirectory = getConfigDirectory();
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, { const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
},
body: JSON.stringify(agentConfig) body: JSON.stringify(agentConfig)
}); });
@@ -231,9 +273,11 @@ export const useAgentsStore = create<AgentsStore>()(
const needsReload = payload?.requiresReload ?? true; const needsReload = payload?.requiresReload ?? true;
if (needsReload) { if (needsReload) {
requiresReload = true; requiresReload = true;
await performFullConfigRefresh({ await refreshAfterOpenCodeRestart({
message: payload?.message, message: payload?.message,
delayMs: payload?.reloadDelayMs, delayMs: payload?.reloadDelayMs,
scopes: ["agents"],
mode: "active",
}); });
return true; return true;
} }
@@ -243,7 +287,8 @@ export const useAgentsStore = create<AgentsStore>()(
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
} }
return loaded; return loaded;
} catch { } catch (error) {
console.error('Failed to create agent:', error);
return false; return false;
} finally { } finally {
if (!requiresReload) { if (!requiresReload) {
@@ -264,17 +309,19 @@ export const useAgentsStore = create<AgentsStore>()(
if (config.temperature !== undefined) agentConfig.temperature = config.temperature; if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
if (config.top_p !== undefined) agentConfig.top_p = config.top_p; if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
if (config.prompt !== undefined) agentConfig.prompt = config.prompt; 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.permission !== undefined) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable; if (config.disable !== undefined) agentConfig.disable = config.disable;
// Get current directory for project-level agent support // Use active project root for project-level agent support.
const currentDirectory = getCurrentDirectory(); const configDirectory = getConfigDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
},
body: JSON.stringify(agentConfig) body: JSON.stringify(agentConfig)
}); });
@@ -287,9 +334,11 @@ export const useAgentsStore = create<AgentsStore>()(
const needsReload = payload?.requiresReload ?? true; const needsReload = payload?.requiresReload ?? true;
if (needsReload) { if (needsReload) {
requiresReload = true; requiresReload = true;
await performFullConfigRefresh({ await refreshAfterOpenCodeRestart({
message: payload?.message, message: payload?.message,
delayMs: payload?.reloadDelayMs, delayMs: payload?.reloadDelayMs,
scopes: ["agents"],
mode: "active",
}); });
return true; return true;
} }
@@ -299,8 +348,9 @@ export const useAgentsStore = create<AgentsStore>()(
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
} }
return loaded; return loaded;
} catch { } catch (error) {
return false; console.error('Failed to update agent:', error);
throw error;
} finally { } finally {
if (!requiresReload) { if (!requiresReload) {
finishConfigUpdate(); finishConfigUpdate();
@@ -312,12 +362,13 @@ export const useAgentsStore = create<AgentsStore>()(
startConfigUpdate("Deleting agent configuration…"); startConfigUpdate("Deleting agent configuration…");
let requiresReload = false; let requiresReload = false;
try { try {
// Get current directory for project-level agent support // Use active project root for project-level agent support.
const currentDirectory = getCurrentDirectory(); const configDirectory = getConfigDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { 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); const payload = await response.json().catch(() => null);
@@ -329,9 +380,11 @@ export const useAgentsStore = create<AgentsStore>()(
const needsReload = payload?.requiresReload ?? true; const needsReload = payload?.requiresReload ?? true;
if (needsReload) { if (needsReload) {
requiresReload = true; requiresReload = true;
await performFullConfigRefresh({ await refreshAfterOpenCodeRestart({
message: payload?.message, message: payload?.message,
delayMs: payload?.reloadDelayMs, delayMs: payload?.reloadDelayMs,
scopes: ["agents"],
mode: "active",
}); });
return true; return true;
} }
@@ -344,7 +397,6 @@ export const useAgentsStore = create<AgentsStore>()(
if (get().selectedAgentName === name) { if (get().selectedAgentName === name) {
set({ selectedAgentName: null }); set({ selectedAgentName: null });
} }
return loaded; return loaded;
} catch { } catch {
return false; return false;
@@ -355,6 +407,7 @@ export const useAgentsStore = create<AgentsStore>()(
} }
}, },
getAgentByName: (name: string) => { getAgentByName: (name: string) => {
const { agents } = get(); const { agents } = get();
return agents.find((a) => a.name === name); 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"); 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 { message, delayMs } = options;
const scopes = normalizeRefreshScopes(options.scopes);
const mode: ConfigRefreshMode = options.mode ?? (scopes.includes("all") ? "projects" : "active");
try { try {
updateConfigUpdateMessage(message || "Reloading OpenCode configuration…"); updateConfigUpdateMessage(message || "Refreshing configuration…");
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.removeItem("agents-store");
window.localStorage.removeItem("config-store");
}
} catch { } catch {
// Ignore local storage cleanup errors // ignore
} }
try { try {
await waitForOpenCodeConnection(delayMs); await waitForOpenCodeConnection(delayMs);
updateConfigUpdateMessage("Refreshing providers and agents…");
const configStore = useConfigStore.getState(); 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([ const refreshProviders = scopes.includes("all") || scopes.includes("providers");
configStore.loadProviders().then(() => undefined), const refreshSdkAgents = scopes.includes("all") || scopes.includes("agents");
agentsStore.loadAgents().then(() => undefined), 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<void>[] = [];
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<void>[] = [];
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 { } catch {
updateConfigUpdateMessage("OpenCode reload failed. Please retry refreshing configuration manually."); updateConfigUpdateMessage("OpenCode refresh failed. Please retry.");
await sleep(1500); await sleep(1500);
} finally { } finally {
finishConfigUpdate(); finishConfigUpdate();
} }
} }
export async function refreshAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) { export async function refreshAfterOpenCodeRestart(options?: {
await performFullConfigRefresh(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…"); startConfigUpdate(options?.message || "Reloading OpenCode configuration…");
try { try {
@@ -482,14 +602,20 @@ export async function reloadOpenCodeConfiguration(options?: { message?: string;
throw new Error(message); throw new Error(message);
} }
const refreshOptions = {
...options,
scopes: options?.scopes ?? ["all"],
mode: options?.mode ?? "projects",
};
if (payload?.requiresReload) { if (payload?.requiresReload) {
await performFullConfigRefresh({ await refreshAfterOpenCodeRestart({
...refreshOptions,
message: payload.message, message: payload.message,
delayMs: payload.reloadDelayMs, delayMs: payload.reloadDelayMs,
}); });
} else { } else {
await refreshAfterOpenCodeRestart(refreshOptions);
await performFullConfigRefresh(options);
} }
} catch (error) { } catch (error) {
console.error('[reloadOpenCodeConfiguration] Failed:', error); console.error('[reloadOpenCodeConfiguration] Failed:', error);
+89 -39
View File
@@ -9,8 +9,8 @@ import {
} from "@/lib/configUpdate"; } from "@/lib/configUpdate";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import { getSafeStorage } from "./utils/safeStorage"; import { getSafeStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore"; import { useProjectsStore } from "@/stores/useProjectsStore";
import { useDirectoryStore } from "@/stores/useDirectoryStore";
export type CommandScope = 'user' | 'project'; export type CommandScope = 'user' | 'project';
@@ -37,6 +37,29 @@ export const isCommandBuiltIn = (command: Command): boolean => {
const CONFIG_EVENT_SOURCE = "useCommandsStore"; const CONFIG_EVENT_SOURCE = "useCommandsStore";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 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 MAX_HEALTH_WAIT_MS = 20000;
const FAST_HEALTH_POLL_INTERVAL_MS = 300; const FAST_HEALTH_POLL_INTERVAL_MS = 300;
const FAST_HEALTH_POLL_ATTEMPTS = 4; const FAST_HEALTH_POLL_ATTEMPTS = 4;
@@ -101,30 +124,60 @@ export const useCommandsStore = create<CommandsStore>()(
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
try { try {
const commands = await opencodeClient.listCommandsWithDetails(); const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
// Fetch scope info for each command // Ensure the list is scoped to the same directory we use for config source detection.
const currentDirectory = useDirectoryStore.getState().currentDirectory; const commands = await opencodeClient.withDirectory(
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; directory,
() => opencodeClient.listCommandsWithDetails()
);
const commandsWithScope = await Promise.all( const commandsWithScope = await Promise.all(
commands.map(async (cmd) => { commands.map(async (cmd) => {
try { 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) { if (response.ok) {
const data = await response.json(); 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; // Prioritize explicit scope
return { ...cmd, scope: scope as CommandScope | undefined }; 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 { } catch (err) {
// Ignore errors fetching scope console.warn(`[CommandsStore] Failed to fetch config for command ${cmd.name}:`, err);
} }
return cmd; 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; return true;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
@@ -156,13 +209,15 @@ export const useCommandsStore = create<CommandsStore>()(
console.log('[CommandsStore] Command config to save:', commandConfig); console.log('[CommandsStore] Command config to save:', commandConfig);
// Get current directory for project-level command support const directory = getRequestDirectory();
const currentDirectory = useDirectoryStore.getState().currentDirectory; const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, { const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(commandConfig) body: JSON.stringify(commandConfig)
}); });
@@ -216,13 +271,15 @@ export const useCommandsStore = create<CommandsStore>()(
console.log('[CommandsStore] Command config to update:', commandConfig); console.log('[CommandsStore] Command config to update:', commandConfig);
// Get current directory for project-level command support const directory = getRequestDirectory();
const currentDirectory = useDirectoryStore.getState().currentDirectory; const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(commandConfig) body: JSON.stringify(commandConfig)
}); });
@@ -263,12 +320,13 @@ export const useCommandsStore = create<CommandsStore>()(
startConfigUpdate("Deleting command configuration…"); startConfigUpdate("Deleting command configuration…");
let requiresReload = false; let requiresReload = false;
try { try {
// Get current directory for project-level command support // Use active project root for project-level command support
const currentDirectory = useDirectoryStore.getState().currentDirectory; const directory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { 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); const payload = await response.json().catch(() => null);
@@ -380,31 +438,23 @@ async function performFullConfigRefresh(options: { message?: string; delayMs?: n
const { message, delayMs } = options; const { message, delayMs } = options;
try { try {
updateConfigUpdateMessage(message || "Reloading OpenCode configuration…"); updateConfigUpdateMessage(message || "Refreshing commands…");
if (typeof window !== "undefined" && window.localStorage) { } catch {
window.localStorage.removeItem("commands-store"); // ignore
window.localStorage.removeItem("config-store");
}
} catch (error) {
console.warn("[CommandsStore] Failed to prepare config refresh:", error);
} }
try { try {
await waitForOpenCodeConnection(delayMs); await waitForOpenCodeConnection(delayMs);
updateConfigUpdateMessage("Refreshing providers and commands…"); updateConfigUpdateMessage("Refreshing commands…");
const configStore = useConfigStore.getState();
const commandsStore = useCommandsStore.getState(); const commandsStore = useCommandsStore.getState();
await Promise.all([ await commandsStore.loadCommands();
configStore.loadProviders().then(() => undefined),
commandsStore.loadCommands().then(() => undefined),
]);
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
} catch (error) { } catch (error) {
console.error("[CommandsStore] Failed to refresh configuration after OpenCode restart:", 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); await sleep(1500);
} finally { } finally {
finishConfigUpdate(); finishConfigUpdate();
+561 -79
View File
@@ -11,6 +11,8 @@ import { filterVisibleAgents } from "./useAgentsStore";
import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop"; import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
import { updateDesktopSettings } from "@/lib/persistence"; 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_API_URL = "https://models.dev/api.json";
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata"; const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
@@ -271,10 +273,70 @@ const fetchModelsDevMetadata = async (): Promise<Map<string, ModelMetadata>> =>
return new Map(); return new Map();
}; };
let modelsMetadataInFlight: Promise<Map<string, ModelMetadata>> | null = null;
const ensureModelsMetadataFetch = (
getModelsMetadata: () => Map<string, ModelMetadata>,
setModelsMetadata: (metadata: Map<string, ModelMetadata>) => 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<string, ModelMetadata>())
.finally(() => {
modelsMetadataInFlight = null;
});
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 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 { interface ConfigStore {
activeDirectoryKey: string;
directoryScoped: Record<string, DirectoryScopedConfig>;
providers: ProviderWithModelList[]; providers: ProviderWithModelList[];
agents: Agent[]; agents: Agent[];
currentProviderId: string; currentProviderId: string;
@@ -290,8 +352,10 @@ interface ConfigStore {
settingsDefaultModel: string | undefined; // format: "provider/model" settingsDefaultModel: string | undefined; // format: "provider/model"
settingsDefaultAgent: string | undefined; settingsDefaultAgent: string | undefined;
loadProviders: () => Promise<void>; activateDirectory: (directory: string | null | undefined) => Promise<void>;
loadAgents: () => Promise<boolean>;
loadProviders: (options?: { directory?: string | null }) => Promise<void>;
loadAgents: (options?: { directory?: string | null }) => Promise<boolean>;
setProvider: (providerId: string) => void; setProvider: (providerId: string) => void;
setModel: (modelId: string) => void; setModel: (modelId: string) => void;
setAgent: (agentName: string | undefined) => void; setAgent: (agentName: string | undefined) => void;
@@ -322,6 +386,9 @@ export const useConfigStore = create<ConfigStore>()(
persist( persist(
(set, get) => ({ (set, get) => ({
activeDirectoryKey: resolveInitialDirectoryKey(),
directoryScoped: {},
providers: [], providers: [],
agents: [], agents: [],
currentProviderId: "", currentProviderId: "",
@@ -336,15 +403,63 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultModel: undefined, settingsDefaultModel: undefined,
settingsDefaultAgent: undefined, settingsDefaultAgent: undefined,
loadProviders: async () => { activateDirectory: async (directory) => {
const previousProviders = get().providers; const directoryKey = toDirectoryKey(directory);
const previousDefaults = get().defaultProviders;
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; let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
try { try {
const metadataPromise = fetchModelsDevMetadata(); ensureModelsMetadataFetch(
const apiResult = await opencodeClient.getProviders(); () => get().modelsMetadata,
(metadata) => set({ modelsMetadata: metadata }),
);
const apiResult = await opencodeClient.withDirectory(
fromDirectoryKey(directoryKey),
() => opencodeClient.getProviders()
);
const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : []; const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : [];
const defaults = apiResult?.default || {}; const defaults = apiResult?.default || {};
@@ -357,16 +472,39 @@ export const useConfigStore = create<ConfigStore>()(
}; };
}); });
// Only store providers and defaults - model/agent selection handled in loadAgents set((state) => {
set({ const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: processedProviders, providers: [],
defaultProviders: defaults, agents: [],
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
providers: processedProviders,
defaultProviders: defaults,
};
const nextState: Partial<ConfigStore> = {
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; return;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
@@ -376,10 +514,38 @@ export const useConfigStore = create<ConfigStore>()(
} }
console.error("Failed to load providers:", lastError); console.error("Failed to load providers:", lastError);
// Preserve previous state on failure instead of clearing it
set({ set((state) => {
providers: previousProviders, const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
defaultProviders: previousDefaults, providers: [],
agents: [],
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
providers: previousProviders,
defaultProviders: previousDefaults,
};
const nextState: Partial<ConfigStore> = {
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<ConfigStore>()(
const { providers } = get(); const { providers } = get();
const provider = providers.find((p) => p.id === providerId); const provider = providers.find((p) => p.id === providerId);
if (provider) { if (!provider) {
return;
}
const firstModel = provider.models[0]; const firstModel = provider.models[0];
const newModelId = firstModel?.id || ""; 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, currentProviderId: providerId,
currentModelId: newModelId, currentModelId: newModelId,
selectedProviderId: providerId, selectedProviderId: providerId,
}); };
}
return {
currentProviderId: providerId,
currentModelId: newModelId,
selectedProviderId: providerId,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
});
}, },
setModel: (modelId: string) => { 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) => { 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) => { saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => {
set((state) => ({ set((state) => {
agentModelSelections: { const directoryKey = state.activeDirectoryKey;
const nextSelections = {
...state.agentModelSelections, ...state.agentModelSelections,
[agentName]: { providerId, modelId }, [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) => { getAgentModelSelection: (agentName: string) => {
@@ -422,40 +689,97 @@ export const useConfigStore = create<ConfigStore>()(
return agentModelSelections[agentName] || null; return agentModelSelections[agentName] || null;
}, },
loadAgents: async () => { loadAgents: async (options) => {
const previousAgents = get().agents; 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; let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
try { try {
// Fetch agents and OpenChamber settings in parallel // Fetch agents and OpenChamber settings in parallel
const [agents, openChamberDefaults] = await Promise.all([ const [agents, openChamberDefaults] = await Promise.all([
opencodeClient.listAgents(), opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()),
fetchOpenChamberDefaults(), fetchOpenChamberDefaults(),
]); ]);
const safeAgents = Array.isArray(agents) ? agents : []; const safeAgents = Array.isArray(agents) ? agents : [];
set({
agents: safeAgents, const providers = get().activeDirectoryKey === directoryKey
// Store settings defaults so setAgent can respect them ? get().providers
settingsDefaultModel: openChamberDefaults.defaultModel, : (get().directoryScoped[directoryKey]?.providers ?? []);
settingsDefaultAgent: openChamberDefaults.defaultAgent,
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<ConfigStore> = {
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) { 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<ConfigStore> = {
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
if (state.activeDirectoryKey === directoryKey) {
nextState.currentAgentName = undefined;
}
return nextState;
});
return true; 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 // Helper to validate model exists in providers
const validateModel = (providerId: string, modelId: string): boolean => { const validateModel = (providerId: string, modelId: string): boolean => {
const provider = providers.find((p) => p.id === providerId); const provider = providers.find((p) => p.id === providerId);
@@ -463,6 +787,14 @@ export const useConfigStore = create<ConfigStore>()(
return provider.models.some((m) => m.id === modelId); 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 // Track invalid settings to clear
const invalidSettings: { defaultModel?: string; defaultAgent?: string } = {}; const invalidSettings: { defaultModel?: string; defaultAgent?: string } = {};
@@ -477,13 +809,6 @@ export const useConfigStore = create<ConfigStore>()(
} }
} }
// 2. Fall back to default logic
if (!resolvedAgent) {
resolvedAgent = fallbackAgent;
}
set({ currentAgentName: resolvedAgent.name });
// --- Model Selection --- // --- Model Selection ---
// Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle // Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle
let resolvedProviderId: string | undefined; let resolvedProviderId: string | undefined;
@@ -524,12 +849,44 @@ export const useConfigStore = create<ConfigStore>()(
} }
} }
if (resolvedProviderId && resolvedModelId) { set((state) => {
set({ const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
currentProviderId: resolvedProviderId, providers,
currentModelId: resolvedModelId, 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<ConfigStore> = {
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) // Clear invalid settings from storage (best-effort cleanup)
if (Object.keys(invalidSettings).length > 0) { if (Object.keys(invalidSettings).length > 0) {
@@ -552,14 +909,75 @@ export const useConfigStore = create<ConfigStore>()(
} }
console.error("Failed to load agents:", lastError); 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<ConfigStore> = {
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
if (state.activeDirectoryKey === directoryKey) {
nextState.agents = previousAgents;
}
return nextState;
});
return false; return false;
}, },
setAgent: (agentName: string | undefined) => { setAgent: (agentName: string | undefined) => {
const { agents, providers, settingsDefaultModel } = get(); 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") { if (agentName && typeof window !== "undefined") {
@@ -608,9 +1026,33 @@ export const useConfigStore = create<ConfigStore>()(
if (parsed) { if (parsed) {
const settingsProvider = providers.find((p) => p.id === parsed.providerId); const settingsProvider = providers.find((p) => p.id === parsed.providerId);
if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) { if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) {
set({ set((state) => {
currentProviderId: parsed.providerId, const directoryKey = state.activeDirectoryKey;
currentModelId: parsed.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,
currentProviderId: parsed.providerId,
currentModelId: parsed.modelId,
};
return {
currentProviderId: parsed.providerId,
currentModelId: parsed.modelId,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
}); });
return; return;
} }
@@ -625,10 +1067,35 @@ export const useConfigStore = create<ConfigStore>()(
const agentModel = agentProvider.models.find((model) => model.id === agent.model!.modelID); const agentModel = agentProvider.models.find((model) => model.id === agent.model!.modelID);
if (agentModel) { if (agentModel) {
set({ set((state) => {
currentProviderId: agent.model!.providerID, const directoryKey = state.activeDirectoryKey;
currentModelId: agent.model!.modelID, const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
selectedProviderId: agent.model!.providerID, 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<ConfigStore>()(
initializeApp: async () => { initializeApp: async () => {
try { try {
console.log("Starting app initialization..."); const debug = streamDebugEnabled();
if (debug) console.log("Starting app initialization...");
const isConnected = await get().checkConnection(); const isConnected = await get().checkConnection();
console.log("Connection check result:", isConnected); if (debug) console.log("Connection check result:", isConnected);
if (!isConnected) { if (!isConnected) {
console.log("Server not connected"); if (debug) console.log("Server not connected");
set({ isConnected: false }); set({ isConnected: false });
return; return;
} }
console.log("Initializing app..."); if (debug) console.log("Initializing app...");
await opencodeClient.initApp(); await opencodeClient.initApp();
console.log("Loading providers..."); if (debug) console.log("Loading providers...");
await get().loadProviders(); await get().loadProviders();
console.log("Loading agents..."); if (debug) console.log("Loading agents...");
await get().loadAgents(); await get().loadAgents();
set({ isInitialized: true, isConnected: true }); set({ isInitialized: true, isConnected: true });
console.log("App initialized successfully"); if (debug) console.log("App initialized successfully");
} catch (error) { } catch (error) {
console.error("Failed to initialize app:", error); console.error("Failed to initialize app:", error);
set({ isInitialized: false, isConnected: false }); 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);
});
}
+6 -151
View File
@@ -1,15 +1,9 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools } from 'zustand/middleware'; import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import type { DirectorySwitchResult } from '@/lib/opencode/client';
import { getDesktopHomeDirectory } from '@/lib/desktop'; import { getDesktopHomeDirectory } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence'; 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 { useFileSearchStore } from '@/stores/useFileSearchStore';
import { emitConfigChange } from '@/lib/configSync';
import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { getSafeStorage } from './utils/safeStorage'; import { getSafeStorage } from './utils/safeStorage';
@@ -37,89 +31,6 @@ const persistedLastDirectory = safeStorage.getItem('lastDirectory');
const initialHasPersistedDirectory = const initialHasPersistedDirectory =
typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0; 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<DirectorySwitchResult | null>,
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) => { const invalidateFileSearchCache = (scope?: string | null) => {
try { try {
@@ -295,26 +206,20 @@ export const useDirectoryStore = create<DirectoryStore>()(
isSwitchingDirectory: false, isSwitchingDirectory: false,
setDirectory: (path: string, options?: { showOverlay?: boolean }) => { setDirectory: (path: string, options?: { showOverlay?: boolean }) => {
void options;
const homeDir = cachedHomeDirectory || get().homeDirectory || safeStorage.getItem('homeDirectory'); const homeDir = cachedHomeDirectory || get().homeDirectory || safeStorage.getItem('homeDirectory');
const resolvedPath = resolveDirectoryPath(path, homeDir); const resolvedPath = resolveDirectoryPath(path, homeDir);
if (streamDebugEnabled()) { if (streamDebugEnabled()) {
console.log('[DirectoryStore] setDirectory called with path:', resolvedPath); console.log('[DirectoryStore] setDirectory called with path:', resolvedPath);
} }
const showOverlay = options?.showOverlay ?? true;
opencodeClient.setDirectory(resolvedPath); opencodeClient.setDirectory(resolvedPath);
invalidateFileSearchCache(); invalidateFileSearchCache();
const restartPromise = notifyOpenCodeWorkingDirectory(resolvedPath, { showOverlay });
if (streamDebugEnabled()) {
console.log('[DirectoryStore] notifyOpenCodeWorkingDirectory initiated');
}
set((state) => { set((state) => {
const newHistory = [...state.directoryHistory.slice(0, state.historyIndex + 1), resolvedPath]; const newHistory = [...state.directoryHistory.slice(0, state.historyIndex + 1), resolvedPath];
safeStorage.setItem('lastDirectory', resolvedPath); safeStorage.setItem('lastDirectory', resolvedPath);
void updateDesktopSettings({ lastDirectory: resolvedPath }); void updateDesktopSettings({ lastDirectory: resolvedPath });
return { return {
@@ -323,21 +228,9 @@ export const useDirectoryStore = create<DirectoryStore>()(
historyIndex: newHistory.length - 1, historyIndex: newHistory.length - 1,
hasPersistedDirectory: true, hasPersistedDirectory: true,
isHomeReady: 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: () => { goBack: () => {
@@ -348,7 +241,6 @@ export const useDirectoryStore = create<DirectoryStore>()(
opencodeClient.setDirectory(newDirectory); opencodeClient.setDirectory(newDirectory);
invalidateFileSearchCache(); invalidateFileSearchCache();
const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory);
safeStorage.setItem('lastDirectory', newDirectory); safeStorage.setItem('lastDirectory', newDirectory);
@@ -359,19 +251,7 @@ export const useDirectoryStore = create<DirectoryStore>()(
historyIndex: newIndex, historyIndex: newIndex,
hasPersistedDirectory: true, hasPersistedDirectory: true,
isHomeReady: true, isHomeReady: true,
isSwitchingDirectory: true, isSwitchingDirectory: false,
});
scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => {
set((state) => {
if (state.currentDirectory !== newDirectory) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
}); });
} }
}, },
@@ -384,7 +264,6 @@ export const useDirectoryStore = create<DirectoryStore>()(
opencodeClient.setDirectory(newDirectory); opencodeClient.setDirectory(newDirectory);
invalidateFileSearchCache(); invalidateFileSearchCache();
const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory);
safeStorage.setItem('lastDirectory', newDirectory); safeStorage.setItem('lastDirectory', newDirectory);
@@ -395,19 +274,7 @@ export const useDirectoryStore = create<DirectoryStore>()(
historyIndex: newIndex, historyIndex: newIndex,
hasPersistedDirectory: true, hasPersistedDirectory: true,
isHomeReady: true, isHomeReady: true,
isSwitchingDirectory: true, isSwitchingDirectory: false,
});
scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => {
set((state) => {
if (state.currentDirectory !== newDirectory) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
}); });
} }
}, },
@@ -484,12 +351,12 @@ export const useDirectoryStore = create<DirectoryStore>()(
updates.currentDirectory = resolvedHome; updates.currentDirectory = resolvedHome;
updates.directoryHistory = [resolvedHome]; updates.directoryHistory = [resolvedHome];
updates.historyIndex = 0; updates.historyIndex = 0;
updates.isSwitchingDirectory = true; updates.isSwitchingDirectory = false;
} else if (currentChanged || historyChanged) { } else if (currentChanged || historyChanged) {
updates.currentDirectory = resolvedCurrent as string; updates.currentDirectory = resolvedCurrent as string;
updates.directoryHistory = resolvedHistory; updates.directoryHistory = resolvedHistory;
updates.historyIndex = Math.min(state.historyIndex, resolvedHistory.length - 1); updates.historyIndex = Math.min(state.historyIndex, resolvedHistory.length - 1);
updates.isSwitchingDirectory = true; updates.isSwitchingDirectory = false;
} }
set(() => updates as Partial<DirectoryStore>); set(() => updates as Partial<DirectoryStore>);
@@ -501,18 +368,6 @@ export const useDirectoryStore = create<DirectoryStore>()(
safeStorage.setItem('lastDirectory', nextDirectory); safeStorage.setItem('lastDirectory', nextDirectory);
void updateDesktopSettings({ 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 }); void updateDesktopSettings({ homeDirectory: resolvedHome });
+30 -4
View File
@@ -6,6 +6,7 @@ import { createWorktree } from '@/lib/git/worktreeService';
import { checkIsGitRepository } from '@/lib/gitApi'; import { checkIsGitRepository } from '@/lib/gitApi';
import { useSessionStore } from './sessionStore'; import { useSessionStore } from './sessionStore';
import { useDirectoryStore } from './useDirectoryStore'; import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
/** /**
* Generate a git-safe slug from a string. * Generate a git-safe slug from a string.
@@ -48,8 +49,33 @@ const sanitizeWorktreeSlug = (value: string): string => {
}; };
const getCurrentDirectory = (): string | null => { const resolveProjectDirectory = (): string | null => {
return useDirectoryStore.getState().currentDirectory ?? 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 { interface MultiRunState {
@@ -99,7 +125,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const directory = getCurrentDirectory(); const directory = resolveProjectDirectory();
if (!directory) { if (!directory) {
set({ error: 'No directory selected', isLoading: false }); set({ error: 'No directory selected', isLoading: false });
return null; return null;
@@ -236,7 +262,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
})(); })();
set({ isLoading: false }); set({ isLoading: false });
return { sessionIds, firstSessionId }; return { groupSlug, sessionIds, firstSessionId };
} catch (error) { } catch (error) {
set({ set({
error: error instanceof Error ? error.message : 'Failed to create Multi-Run', error: error instanceof Error ? error.message : 'Failed to create Multi-Run',
+447
View File
@@ -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<string>();
const seenPaths = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
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<ProjectsStore>()(
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<DesktopSettings>).detail;
if (detail && typeof detail === 'object') {
useProjectsStore.getState().synchronizeFromSettings(detail);
}
});
}
+16 -7
View File
@@ -2,7 +2,7 @@ import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand";
import { devtools } from "zustand/middleware"; import { devtools } from "zustand/middleware";
import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; 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 type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes";
import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } from "./types/sessionTypes"; import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } from "./types/sessionTypes";
@@ -66,6 +66,7 @@ export const useSessionStore = create<SessionStore>()(
(set, get) => ({ (set, get) => ({
sessions: [], sessions: [],
sessionsByDirectory: new Map(),
currentSessionId: null, currentSessionId: null,
lastLoadedDirectory: null, lastLoadedDirectory: null,
messages: new Map(), messages: new Map(),
@@ -87,6 +88,7 @@ export const useSessionStore = create<SessionStore>()(
webUICreatedSessions: new Set(), webUICreatedSessions: new Set(),
worktreeMetadata: new Map(), worktreeMetadata: new Map(),
availableWorktrees: [], availableWorktrees: [],
availableWorktreesByProject: new Map(),
currentAgentContext: new Map(), currentAgentContext: new Map(),
sessionContextUsage: new Map(), sessionContextUsage: new Map(),
sessionAgentEditModes: new Map(), sessionAgentEditModes: new Map(),
@@ -420,7 +422,7 @@ export const useSessionStore = create<SessionStore>()(
markMessageStreamSettled: (messageId: string) => useMessageStore.getState().markMessageStreamSettled(messageId), markMessageStreamSettled: (messageId: string) => useMessageStore.getState().markMessageStreamSettled(messageId),
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record<string, unknown>) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo), updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record<string, unknown>) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo),
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null), updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null),
addPermission: (permission: Permission) => { addPermission: (permission: PermissionRequest) => {
const contextData = { const contextData = {
currentAgentContext: useContextStore.getState().currentAgentContext, currentAgentContext: useContextStore.getState().currentAgentContext,
sessionAgentSelections: useContextStore.getState().sessionAgentSelections, sessionAgentSelections: useContextStore.getState().sessionAgentSelections,
@@ -428,9 +430,10 @@ export const useSessionStore = create<SessionStore>()(
}; };
return usePermissionStore.getState().addPermission(permission, contextData); 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(), clearError: () => useSessionManagementStore.getState().clearError(),
getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory), getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory),
getDirectoryForSession: (sessionId: string) => useSessionManagementStore.getState().getDirectoryForSession(sessionId),
getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId), getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId),
getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId), getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId),
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => useMessageStore.getState().syncMessages(sessionId, messages), syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => useMessageStore.getState().syncMessages(sessionId, messages),
@@ -507,6 +510,7 @@ export const useSessionStore = create<SessionStore>()(
return useContextStore.getState().pollForTokenUpdates(sessionId, messageId, messages, maxAttempts); return useContextStore.getState().pollForTokenUpdates(sessionId, messageId, messages, maxAttempts);
}, },
updateSession: (session: Session) => useSessionManagementStore.getState().updateSession(session), updateSession: (session: Session) => useSessionManagementStore.getState().updateSession(session),
removeSessionFromStore: (sessionId: string) => useSessionManagementStore.getState().removeSessionFromStore(sessionId),
revertToMessage: async (sessionId: string, messageId: string) => { revertToMessage: async (sessionId: string, messageId: string) => {
// Get the message text before reverting // Get the message text before reverting
@@ -559,7 +563,7 @@ export const useSessionStore = create<SessionStore>()(
const sessions = get().sessions; const sessions = get().sessions;
const currentSession = sessions.find(s => s.id === sessionId); 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) { if (userMessages.length === 0) {
return; return;
} }
@@ -576,7 +580,7 @@ export const useSessionStore = create<SessionStore>()(
targetMessage = userMessages[userMessages.length - 1]; targetMessage = userMessages[userMessages.length - 1];
} }
// Silent no-op like OpenCode CLI // No-op when there is nothing to undo/redo
if (!targetMessage) { if (!targetMessage) {
return; return;
} }
@@ -598,7 +602,7 @@ export const useSessionStore = create<SessionStore>()(
const currentSession = sessions.find(s => s.id === sessionId); const currentSession = sessions.find(s => s.id === sessionId);
const revertToId = currentSession?.revert?.messageID; const revertToId = currentSession?.revert?.messageID;
// Silent no-op like OpenCode CLI // No-op when there is nothing to undo/redo
if (!revertToId) { if (!revertToId) {
return; return;
} }
@@ -708,13 +712,15 @@ useSessionManagementStore.subscribe((state, prevState) => {
if ( if (
state.sessions === prevState.sessions && state.sessions === prevState.sessions &&
state.sessionsByDirectory === prevState.sessionsByDirectory &&
state.currentSessionId === prevState.currentSessionId && state.currentSessionId === prevState.currentSessionId &&
state.lastLoadedDirectory === prevState.lastLoadedDirectory && state.lastLoadedDirectory === prevState.lastLoadedDirectory &&
state.isLoading === prevState.isLoading && state.isLoading === prevState.isLoading &&
state.error === prevState.error && state.error === prevState.error &&
state.webUICreatedSessions === prevState.webUICreatedSessions && state.webUICreatedSessions === prevState.webUICreatedSessions &&
state.worktreeMetadata === prevState.worktreeMetadata && state.worktreeMetadata === prevState.worktreeMetadata &&
state.availableWorktrees === prevState.availableWorktrees state.availableWorktrees === prevState.availableWorktrees &&
state.availableWorktreesByProject === prevState.availableWorktreesByProject
) { ) {
return; return;
} }
@@ -723,6 +729,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
useSessionStore.setState({ useSessionStore.setState({
sessions: state.sessions, sessions: state.sessions,
sessionsByDirectory: state.sessionsByDirectory,
currentSessionId: draftOpen ? null : state.currentSessionId, currentSessionId: draftOpen ? null : state.currentSessionId,
lastLoadedDirectory: state.lastLoadedDirectory, lastLoadedDirectory: state.lastLoadedDirectory,
isLoading: state.isLoading, isLoading: state.isLoading,
@@ -730,6 +737,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
webUICreatedSessions: state.webUICreatedSessions, webUICreatedSessions: state.webUICreatedSessions,
worktreeMetadata: state.worktreeMetadata, worktreeMetadata: state.worktreeMetadata,
availableWorktrees: state.availableWorktrees, availableWorktrees: state.availableWorktrees,
availableWorktreesByProject: state.availableWorktreesByProject,
}); });
}); });
@@ -873,6 +881,7 @@ useSessionStore.setState({
webUICreatedSessions: useSessionManagementStore.getState().webUICreatedSessions, webUICreatedSessions: useSessionManagementStore.getState().webUICreatedSessions,
worktreeMetadata: useSessionManagementStore.getState().worktreeMetadata, worktreeMetadata: useSessionManagementStore.getState().worktreeMetadata,
availableWorktrees: useSessionManagementStore.getState().availableWorktrees, availableWorktrees: useSessionManagementStore.getState().availableWorktrees,
availableWorktreesByProject: useSessionManagementStore.getState().availableWorktreesByProject,
messages: useMessageStore.getState().messages, messages: useMessageStore.getState().messages,
sessionMemoryState: useMessageStore.getState().sessionMemoryState, sessionMemoryState: useMessageStore.getState().sessionMemoryState,
messageStreamStates: useMessageStore.getState().messageStreamStates, messageStreamStates: useMessageStore.getState().messageStreamStates,
@@ -13,8 +13,14 @@ import type {
} from '@/lib/api/types'; } from '@/lib/api/types';
import { useSkillsStore } from '@/stores/useSkillsStore'; import { useSkillsStore } from '@/stores/useSkillsStore';
import { opencodeClient } from '@/lib/opencode/client';
const getCurrentDirectory = (): string | null => { const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__; const store = (window as any).__zustand_directory_store__;
@@ -24,6 +30,7 @@ const getCurrentDirectory = (): string | null => {
} catch { } catch {
// ignore // ignore
} }
return null; return null;
}; };
+8 -1
View File
@@ -8,8 +8,14 @@ import {
} from "@/lib/configUpdate"; } from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage"; import { getSafeStorage } from "./utils/safeStorage";
// Access directory store without circular dependency import { opencodeClient } from '@/lib/opencode/client';
const getCurrentDirectory = (): string | null => { const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__; const store = (window as any).__zustand_directory_store__;
@@ -19,6 +25,7 @@ const getCurrentDirectory = (): string | null => {
} catch { } catch {
// ignore // ignore
} }
return null; return null;
}; };
+5 -1
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { devtools } from "zustand/middleware"; import { devtools } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client"; import { opencodeClient } from "@/lib/opencode/client";
import { useSessionStore } from "./useSessionStore";
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
export type TodoPriority = "high" | "medium" | "low"; export type TodoPriority = "high" | "medium" | "low";
@@ -46,7 +47,10 @@ export const useTodoStore = create<TodoStore>()(
set({ isLoading: true }); set({ isLoading: true });
try { 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); const todos = rawTodos.map(normalizeTodo);
set((state) => { set((state) => {
+52 -12
View File
@@ -15,14 +15,33 @@ export const isEditPermissionType = (type?: string | null): boolean => {
return EDIT_PERMISSION_TOOL_NAMES.has(type.toLowerCase()); 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') { if (typeof window === 'undefined') {
return 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) { if (!agentName) {
return undefined; return undefined;
} }
@@ -31,24 +50,45 @@ const getAgentDefinition = (agentName?: string): { name: string; permission?: {
const configStore = resolveConfigStore(); const configStore = resolveConfigStore();
if (configStore?.getState) { if (configStore?.getState) {
const state = 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; 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 => { export const getAgentDefaultEditPermission = (agentName?: string): EditPermissionMode => {
const agent = getAgentDefinition(agentName); const agent = getAgentDefinition(agentName);
if (!agent) { if (!agent) {
return 'ask'; return 'ask';
} }
const permission = agent.permission?.edit; const action = resolvePermissionAction(agent.permission, 'edit');
if (permission === 'allow' || permission === 'ask' || permission === 'deny' || permission === 'full') { return action;
return permission;
}
const editToolEnabled = agent.tools ? agent.tools.edit !== false : true;
return editToolEnabled ? 'ask' : 'deny';
}; };
+2
View File
@@ -36,6 +36,8 @@ export interface CreateMultiRunParams {
} }
export interface CreateMultiRunResult { export interface CreateMultiRunResult {
/** Canonical group slug used in session titles */
groupSlug: string;
/** Session IDs created successfully (in selection order) */ /** Session IDs created successfully (in selection order) */
sessionIds: string[]; sessionIds: string[];
/** First successfully created session ID, if any */ /** First successfully created session ID, if any */
+17 -15
View File
@@ -1,26 +1,28 @@
export interface Permission { export interface PermissionRequest {
id: string; 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; sessionID: string;
messageID: string; permission: string;
callID?: string; patterns: string[];
title: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
time: { always: string[];
created: number;
};
tool?: { tool?: {
messageID: string; messageID: string;
callID: string; callID: string;
}; };
} }
export interface PermissionEvent { export type PermissionResponse = 'once' | 'always' | 'reject';
type: 'permission.updated';
properties: Permission; export interface PermissionAskedEvent {
type: 'permission.asked';
properties: PermissionRequest;
} }
export type PermissionResponse = 'once' | 'always' | 'reject'; export interface PermissionRepliedEvent {
type: 'permission.replied';
properties: {
sessionID: string;
requestID: string;
reply: PermissionResponse;
};
}
+1 -1
View File
@@ -197,7 +197,7 @@
}, },
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.0.209", "@opencode-ai/sdk": "^1.1.1",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
@@ -15,6 +15,7 @@ export class AgentManagerPanelProvider {
private _cachedError?: string; private _cachedError?: string;
private _sseCounter = 0; private _sseCounter = 0;
private _sseStreams = new Map<string, AbortController>(); private _sseStreams = new Map<string, AbortController>();
private _sseHeartbeats = new Map<string, ReturnType<typeof setInterval>>();
constructor( constructor(
private readonly _context: vscode.ExtensionContext, private readonly _context: vscode.ExtensionContext,
@@ -58,6 +59,12 @@ export class AgentManagerPanelProvider {
controller.abort(); controller.abort();
} }
this._sseStreams.clear(); this._sseStreams.clear();
for (const heartbeat of this._sseHeartbeats.values()) {
clearInterval(heartbeat);
}
this._sseHeartbeats.clear();
this._panel = undefined; this._panel = undefined;
}, null, this._context.subscriptions); }, null, this._context.subscriptions);
@@ -162,12 +169,29 @@ export class AgentManagerPanelProvider {
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
let response: Response; let response: Response;
let wrapAsGlobal = false;
const requestHeaders = this._buildSseHeaders(headers || {});
try { try {
response = await fetch(targetUrl, { response = await fetch(targetUrl, {
method: 'GET', method: 'GET',
headers: this._buildSseHeaders(headers || {}), headers: requestHeaders,
signal: controller.signal, 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
return { return {
@@ -196,6 +220,21 @@ export class AgentManagerPanelProvider {
this._sseStreams.set(streamId, controller); 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 () => { (async () => {
try { try {
const reader = responseBody.getReader(); const reader = responseBody.getReader();
@@ -216,7 +255,10 @@ export class AgentManagerPanelProvider {
const blocks = sseBuffer.split('\n\n'); const blocks = sseBuffer.split('\n\n');
sseBuffer = blocks.pop() ?? ''; sseBuffer = blocks.pop() ?? '';
if (blocks.length > 0) { 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(''); const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
} }
@@ -229,7 +271,10 @@ export class AgentManagerPanelProvider {
} }
if (sseBuffer) { if (sseBuffer) {
if (shouldInjectActivity) { 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(''); const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
} else { } else {
@@ -252,6 +297,11 @@ export class AgentManagerPanelProvider {
} }
} finally { } finally {
this._sseStreams.delete(streamId); 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(); controller.abort();
this._sseStreams.delete(streamId); 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 } }; 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<string, unknown> } | 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<string, unknown>;
const nestedPayload = record.payload;
const payload = nestedPayload && typeof nestedPayload === 'object'
? (nestedPayload as Record<string, unknown>)
: 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 expandSseBlocksWithActivity = (blocks: string[]): string[] => {
const expanded: string[] = []; const expanded: string[] = [];
for (const block of blocks) { for (const block of blocks) {
+128 -3
View File
@@ -19,6 +19,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
private _cachedError?: string; private _cachedError?: string;
private _sseCounter = 0; private _sseCounter = 0;
private _sseStreams = new Map<string, AbortController>(); private _sseStreams = new Map<string, AbortController>();
private _sseHeartbeats = new Map<string, ReturnType<typeof setInterval>>();
constructor( constructor(
private readonly _context: vscode.ExtensionContext, private readonly _context: vscode.ExtensionContext,
@@ -195,12 +196,30 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
let response: Response; let response: Response;
let wrapAsGlobal = false;
const requestHeaders = this._buildSseHeaders(headers || {});
try { try {
response = await fetch(targetUrl, { response = await fetch(targetUrl, {
method: 'GET', method: 'GET',
headers: this._buildSseHeaders(headers || {}), headers: requestHeaders,
signal: controller.signal, 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
return { return {
@@ -229,6 +248,21 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
this._sseStreams.set(streamId, controller); 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 () => { (async () => {
try { try {
const reader = responseBody.getReader(); const reader = responseBody.getReader();
@@ -251,7 +285,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
const blocks = sseBuffer.split('\n\n'); const blocks = sseBuffer.split('\n\n');
sseBuffer = blocks.pop() ?? ''; sseBuffer = blocks.pop() ?? '';
if (blocks.length > 0) { 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(''); const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
} }
@@ -264,7 +301,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
} }
if (sseBuffer) { if (sseBuffer) {
if (shouldInjectActivity) { 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(''); const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
} else { } else {
@@ -287,6 +327,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
} }
} finally { } finally {
this._sseStreams.delete(streamId); 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(); controller.abort();
this._sseStreams.delete(streamId); 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 } }; 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<string, unknown> } | 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<string, unknown>;
const nestedPayload = record.payload;
const payload = nestedPayload && typeof nestedPayload === 'object'
? (nestedPayload as Record<string, unknown>)
: 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 expandSseBlocksWithActivity = (blocks: string[]): string[] => {
const expanded: string[] = []; const expanded: string[] = [];
for (const block of blocks) { for (const block of blocks) {
+18 -8
View File
@@ -618,23 +618,28 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
} }
case 'api:config/agents': { case 'api:config/agents': {
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> }; const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown>; directory?: string };
const agentName = typeof name === 'string' ? name.trim() : ''; const agentName = typeof name === 'string' ? name.trim() : '';
if (!agentName) { if (!agentName) {
return { id, type, success: false, error: 'Agent name is required' }; return { id, type, success: false, error: 'Agent name is required' };
} }
// Get working directory for project-level agent support // Use directory from request if provided, otherwise fall back to workspace
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; 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'; const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (normalizedMethod === 'GET') { if (normalizedMethod === 'GET') {
const sources = getAgentSources(agentName, workingDirectory); const sources = getAgentSources(agentName, workingDirectory);
const scope = sources.md.exists
? sources.md.scope
: (sources.json.exists ? sources.json.scope : null);
return { return {
id, id,
type, type,
success: true, 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': { case 'api:config/commands': {
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> }; const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown>; directory?: string };
const commandName = typeof name === 'string' ? name.trim() : ''; const commandName = typeof name === 'string' ? name.trim() : '';
if (!commandName) { if (!commandName) {
return { id, type, success: false, error: 'Command name is required' }; return { id, type, success: false, error: 'Command name is required' };
} }
// Get working directory for project-level command support // Use directory from request if provided, otherwise fall back to workspace
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; 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'; const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (normalizedMethod === 'GET') { if (normalizedMethod === 'GET') {
const sources = getCommandSources(commandName, workingDirectory); const sources = getCommandSources(commandName, workingDirectory);
const scope = sources.md.exists
? sources.md.scope
: (sources.json.exists ? sources.json.scope : null);
return { return {
id, id,
type, type,
success: true, 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 },
}; };
} }
+5 -2
View File
@@ -305,9 +305,10 @@ export async function activate(context: vscode.ExtensionContext) {
}); });
const elapsedMs = Date.now() - startedAt; const elapsedMs = Date.now() - startedAt;
const contentType = resp.headers.get('content-type') || ''; const contentType = resp.headers.get('content-type') || '';
const isJson = contentType.toLowerCase().includes('json') && !contentType.toLowerCase().includes('text/html');
let summary = ''; let summary = '';
if (contentType.includes('application/json')) { if (isJson) {
const json = await resp.json().catch(() => null); const json = await resp.json().catch(() => null);
if (Array.isArray(json)) { if (Array.isArray(json)) {
summary = `json[array] len=${json.length}`; 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'; 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) { } catch (error) {
const elapsedMs = Date.now() - startedAt; const elapsedMs = Date.now() - startedAt;
const isAbort = const isAbort =
@@ -356,6 +357,8 @@ export async function activate(context: vscode.ExtensionContext) {
{ label: 'commands', path: '/command', includeDirectory: true }, { label: 'commands', path: '/command', includeDirectory: true },
{ label: 'project', path: '/project/current', includeDirectory: true }, { label: 'project', path: '/project/current', includeDirectory: true },
{ label: 'path', path: '/path', 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 }, { label: 'sessionStatus', path: '/session/status', includeDirectory: true },
]; ];
+195 -140
View File
@@ -1,15 +1,16 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { spawn, ChildProcess, spawnSync } from 'child_process'; import { spawn, ChildProcess, spawnSync } from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
// Optimized timeouts for faster startup // Optimized timeouts for faster startup
const PORT_DETECTION_TIMEOUT_MS = 10000; const READY_CHECK_TIMEOUT_MS = 30000;
const READY_CHECK_TIMEOUT_MS = 12000; const READY_CHECK_INTERVAL_MS = 250; // Avoid hammering the server during startup
const READY_CHECK_INTERVAL_MS = 100; // Fast polling during startup
const HEALTH_CHECK_INTERVAL_MS = 5000; const HEALTH_CHECK_INTERVAL_MS = 5000;
const SHUTDOWN_TIMEOUT_MS = 3000; const SHUTDOWN_TIMEOUT_MS = 3000;
const DEFAULT_OPENCODE_PORT = 4096;
// Regex to detect port from CLI output (matches desktop pattern) // Regex to detect port from CLI output (matches desktop pattern)
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi; const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
@@ -65,13 +66,35 @@ export interface OpenCodeManager {
function isExecutable(filePath: string): boolean { function isExecutable(filePath: string): boolean {
try { try {
const stats = fs.statSync(filePath);
if (!stats.isFile()) return false;
if (process.platform === 'win32') return true;
fs.accessSync(filePath, fs.constants.X_OK); fs.accessSync(filePath, fs.constants.X_OK);
return fs.statSync(filePath).isFile(); return true;
} catch { } catch {
return false; 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 { function getLoginShellPath(): string | null {
if (process.platform === 'win32') { if (process.platform === 'win32') {
return null; return null;
@@ -126,17 +149,47 @@ function buildAugmentedPath(): string {
function resolveCliPath(): string | null { function resolveCliPath(): string | null {
// First check explicit candidates // First check explicit candidates
for (const candidate of BIN_CANDIDATES) { for (const candidate of BIN_CANDIDATES) {
if (candidate && isExecutable(candidate)) { if (!candidate) continue;
if (isExecutable(candidate)) {
return 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 // Then search in augmented PATH
const augmentedPath = buildAugmentedPath(); const augmentedPath = buildAugmentedPath();
for (const segment of augmentedPath.split(path.delimiter)) { if (process.platform === 'win32') {
const candidate = path.join(segment, 'opencode'); try {
if (isExecutable(candidate)) { const result = spawnSync('where', ['opencode'], {
return candidate; 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<boolean> {
signal: controller.signal, signal: controller.signal,
headers: { Accept: 'application/json' }, 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); clearTimeout(timeout);
return true; return true;
} }
@@ -208,6 +263,85 @@ async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
return false; 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<boolean> {
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<boolean> {
if (!Number.isFinite(port) || port <= 0) return false;
return await new Promise<boolean>((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<number> {
return await new Promise<number>((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 // eslint-disable-next-line @typescript-eslint/no-unused-vars
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager { export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
let childProcess: ChildProcess | null = null; let childProcess: ChildProcess | null = null;
@@ -224,7 +358,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
// Port detection state (like desktop) // Port detection state (like desktop)
let detectedPort: number | null = null; let detectedPort: number | null = null;
let portWaiters: Array<(port: number) => void> = [];
// OpenCode API prefix detection (some versions serve under /api) // OpenCode API prefix detection (some versions serve under /api)
let apiPrefix: string = ''; let apiPrefix: string = '';
@@ -280,6 +413,31 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
return `http://localhost:${port}${prefix}`; return `http://localhost:${port}${prefix}`;
}; };
const probeOpenCodeAtPort = async (port: number, quick = false): Promise<string | null> => {
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<boolean> => {
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) => { const detectApiPrefixFromOutput = (text: string) => {
if (!text) return; if (!text) return;
URL_REGEX.lastIndex = 0; 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<string> => {
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) { function setStatus(newStatus: ConnectionStatus, error?: string) {
if (status !== newStatus || lastError !== error) { if (status !== newStatus || lastError !== error) {
status = newStatus; status = newStatus;
@@ -359,20 +467,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
} }
function setDetectedPort(port: number) { function setDetectedPort(port: number) {
if (detectedPort !== port) { detectedPort = port;
detectedPort = port;
// Notify all waiters
const waiters = portWaiters;
portWaiters = [];
for (const notify of waiters) {
try {
notify(port);
} catch {
// Ignore waiter errors
}
}
}
} }
function detectPortFromOutput(text: string) { function detectPortFromOutput(text: string) {
@@ -382,6 +477,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
while ((match = URL_REGEX.exec(text)) !== null) { while ((match = URL_REGEX.exec(text)) !== null) {
const port = parseInt(match[1], 10); const port = parseInt(match[1], 10);
if (Number.isFinite(port) && port > 0) { if (Number.isFinite(port) && port > 0) {
if (detectedPort !== null && detectedPort !== port) {
return;
}
setDetectedPort(port); setDetectedPort(port);
const inferred = inferPrefixFromLogPath(match[2] || ''); const inferred = inferPrefixFromLogPath(match[2] || '');
if (inferred !== null) { if (inferred !== null) {
@@ -396,45 +494,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
if (fallbackMatch) { if (fallbackMatch) {
const port = parseInt(fallbackMatch[1], 10); const port = parseInt(fallbackMatch[1], 10);
if (Number.isFinite(port) && port > 0) { if (Number.isFinite(port) && port > 0) {
if (detectedPort !== null && detectedPort !== port) {
return;
}
setDetectedPort(port); setDetectedPort(port);
} }
} }
} }
async function waitForPort(timeoutMs: number): Promise<number> {
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<boolean> {
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 { function getApiUrl(): string | null {
if (useConfiguredUrl && configuredApiUrl) { if (useConfiguredUrl && configuredApiUrl) {
return configuredApiUrl.replace(/\/+$/, ''); 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 user configured an external API URL, do NOT start a local CLI instance.
if (useConfiguredUrl && configuredApiUrl) { if (useConfiguredUrl && configuredApiUrl) {
setStatus('connecting'); setStatus('connecting');
const healthy = await checkHealth(configuredApiUrl); const healthy = await checkReady(configuredApiUrl, workingDirectory, false);
if (healthy) { if (healthy) {
setStatus('connected'); setStatus('connected');
startHealthCheck(); startHealthCheck();
@@ -495,7 +562,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
// Check for existing running instance (only if port is known) // Check for existing running instance (only if port is known)
const currentUrl = getApiUrl(); const currentUrl = getApiUrl();
if (currentUrl && await checkHealth(currentUrl)) { if (currentUrl && await checkReady(currentUrl, workingDirectory, false)) {
setStatus('connected'); setStatus('connected');
startHealthCheck(); startHealthCheck();
return; return;
@@ -524,22 +591,29 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); 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 { try {
const portToUse = await (async () => {
if (await isTcpPortAvailable(DEFAULT_OPENCODE_PORT)) {
return DEFAULT_OPENCODE_PORT;
}
return await getEphemeralPort();
})();
const augmentedEnv = { const augmentedEnv = {
...process.env, ...process.env,
PATH: buildAugmentedPath(), PATH: buildAugmentedPath(),
}; };
childProcess = spawn(cliPath!, ['serve', '--port', portArg], { childProcess = spawn(cliPath!, ['serve', '--port', portToUse.toString()], {
cwd: spawnCwd, cwd: spawnCwd,
env: augmentedEnv, env: augmentedEnv,
detached: false, detached: false,
stdio: ['ignore', 'pipe', 'pipe'], 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) => { childProcess.stdout?.on('data', (data) => {
const text = data.toString(); const text = data.toString();
detectPortFromOutput(text); detectPortFromOutput(text);
@@ -566,34 +640,13 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
lastExitCode = typeof code === 'number' ? code : null; lastExitCode = typeof code === 'number' ? code : null;
}); });
// Wait for port detection (port comes from stdout/stderr) const ready = await waitForOpenCodeReadyAtPort(portToUse, READY_CHECK_TIMEOUT_MS);
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);
if (ready) { if (ready) {
setStatus('connected'); setStatus('connected');
startHealthCheck(); startHealthCheck();
} else { } else {
setStatus('error', 'OpenCode API did not become ready in time'); setStatus('error', 'OpenCode API did not become ready in time');
await stop();
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -635,6 +688,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
if (target === workingDirectory) { if (target === workingDirectory) {
return { success: true, restarted: false, path: target }; 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; workingDirectory = target;
// When pointing at an external API URL, avoid restarting a local CLI process. // 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 }; return { success: true, restarted: false, path: target };
} }
await restart(); return { success: true, restarted: false, path: target };
return { success: true, restarted: true, path: target };
} }
return { return {
+39 -4
View File
@@ -172,9 +172,37 @@ const writePromptFile = (filePath: string, content: string) => {
fs.writeFileSync(filePath, content, 'utf8'); 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 => { const getProjectConfigPath = (workingDirectory?: string): string | null => {
if (!workingDirectory) return 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) => ({ const getConfigPaths = (workingDirectory?: string) => ({
@@ -289,7 +317,14 @@ const parseMdFile = (filePath: string): { frontmatter: Record<string, unknown>;
const content = fs.readFileSync(filePath, 'utf8'); const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content.trim() }; if (!match) return { frontmatter: {}, body: content.trim() };
return { frontmatter: (yaml.parse(match[1]) || {}) as Record<string, unknown>, body: (match[2] || '').trim() }; let frontmatter: Record<string, unknown> = {};
try {
frontmatter = (yaml.parse(match[1]) || {}) as Record<string, unknown>;
} 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<string, unknown>, body: string) => { const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, body: string) => {
@@ -541,10 +576,11 @@ export const getCommandSources = (commandName: string, workingDirectory?: string
const jsonSource = getJsonEntrySource(layers, 'command', commandName); const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const commandSection = jsonSource.section as Record<string, unknown> | undefined; const commandSection = jsonSource.section as Record<string, unknown> | undefined;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath; 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 = { const sources: ConfigSources = {
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] }, 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 }, projectMd: { exists: projectExists, path: projectPath },
userMd: { exists: userExists, path: userPath } userMd: { exists: userExists, path: userPath }
}; };
@@ -1141,4 +1177,3 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
throw new Error(`Skill "${skillName}" not found`); throw new Error(`Skill "${skillName}" not found`);
} }
}; };
+1
View File
@@ -23,6 +23,7 @@ export default defineConfig({
define: { define: {
'process.env.NODE_ENV': JSON.stringify('production'), 'process.env.NODE_ENV': JSON.stringify('production'),
'global': 'globalThis', 'global': 'globalThis',
'__OPENCHAMBER_WEBVIEW_BUILD_TIME__': JSON.stringify(new Date().toISOString()),
}, },
envPrefix: ['VITE_'], envPrefix: ['VITE_'],
optimizeDeps: { optimizeDeps: {
+1 -1
View File
@@ -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 // Use same endpoints as web - fetch interceptor handles URL rewriting
const SETTINGS_ENDPOINT = '/api/config/settings'; const SETTINGS_ENDPOINT = '/api/config/settings';
+1 -1
View File
@@ -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'; import { executeVSCodeCommand } from './bridge';
export const createVSCodeActionsAPI = (): VSCodeAPI => ({ export const createVSCodeActionsAPI = (): VSCodeAPI => ({
+73 -11
View File
@@ -1,16 +1,18 @@
import { createVSCodeAPIs } from './api'; import { createVSCodeAPIs } from './api';
import { onCommand, onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge'; 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 { import {
buildVSCodeThemeFromPalette, buildVSCodeThemeFromPalette,
readVSCodeThemePalette, readVSCodeThemePalette,
type VSCodeThemeKind, type VSCodeThemeKind,
type VSCodeThemePayload, type VSCodeThemePayload,
} from '../../ui/src/lib/theme/vscode/adapter'; } from '@openchamber/ui/lib/theme/vscode/adapter';
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected'; type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
type PanelType = 'chat' | 'agentManager'; type PanelType = 'chat' | 'agentManager';
declare const __OPENCHAMBER_WEBVIEW_BUILD_TIME__: string;
declare global { declare global {
interface Window { interface Window {
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs; __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
@@ -31,7 +33,15 @@ declare global {
} }
console.log('[OpenChamber] VS Code webview starting...'); 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__); 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(); window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
@@ -238,9 +248,19 @@ onThemeChange((payload) => {
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder; const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
if (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 { try {
window.localStorage.setItem('lastDirectory', workspaceFolder); window.localStorage.setItem('lastDirectory', normalizedWorkspaceFolder);
window.localStorage.setItem('homeDirectory', normalizedWorkspaceFolder);
} catch (error) { } catch (error) {
console.warn('Failed to persist workspace folder', 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 name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase(); const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {}; 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 { 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' } }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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 name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase(); const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {}; 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 { 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' } }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -671,7 +733,7 @@ onCommand('addToContext', (payload) => {
const { text } = payload as { text: string }; const { text } = payload as { text: string };
// Import the store dynamically to avoid circular dependencies // 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 store = useSessionStore.getState();
const currentText = store.pendingInputText || ''; const currentText = store.pendingInputText || '';
// Append to existing text with double newline separator // Append to existing text with double newline separator
@@ -685,8 +747,8 @@ onCommand('createSessionWithPrompt', (payload) => {
const { prompt } = payload as { prompt: string }; const { prompt } = payload as { prompt: string };
Promise.all([ Promise.all([
import('../../ui/src/stores/useSessionStore'), import('@/stores/useSessionStore'),
import('../../ui/src/stores/useConfigStore'), import('@/stores/useConfigStore'),
]).then(([{ useSessionStore }, { useConfigStore }]) => { ]).then(([{ useSessionStore }, { useConfigStore }]) => {
const sessionStore = useSessionStore.getState(); const sessionStore = useSessionStore.getState();
const configStore = useConfigStore.getState(); const configStore = useConfigStore.getState();
@@ -719,7 +781,7 @@ onCommand('createSessionWithPrompt', (payload) => {
// Listen for newSession command from extension title bar button // Listen for newSession command from extension title bar button
onCommand('newSession', () => { onCommand('newSession', () => {
import('../../ui/src/stores/useSessionStore').then(({ useSessionStore }) => { import('@/stores/useSessionStore').then(({ useSessionStore }) => {
const store = useSessionStore.getState(); const store = useSessionStore.getState();
store.openNewSessionDraft(); store.openNewSessionDraft();
}); });
@@ -734,7 +796,7 @@ onCommand('showSettings', () => {
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } })); window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } }));
}); });
import('../../ui/src/main') import('@/main')
.then(async () => { .then(async () => {
await waitForUiMount(); await waitForUiMount();
uiMounted = true; uiMounted = true;
+1 -1
View File
@@ -25,7 +25,7 @@
"@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+423 -85
View File
@@ -6,6 +6,7 @@ import fs from 'fs';
import http from 'http'; import http from 'http';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import os from 'os'; import os from 'os';
import crypto from 'crypto';
import { createUiAuth } from './lib/ui-auth.js'; import { createUiAuth } from './lib/ui-auth.js';
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.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) => { const sanitizeTypographySizesPartial = (input) => {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return undefined; return undefined;
@@ -384,6 +455,47 @@ const sanitizeSkillCatalogs = (input) => {
return result; 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) => { const sanitizeSettingsUpdate = (payload) => {
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return {}; return {};
@@ -413,6 +525,15 @@ const sanitizeSettingsUpdate = (payload) => {
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
result.homeDirectory = candidate.homeDirectory; 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)) { if (Array.isArray(candidate.approvedDirectories)) {
result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories); result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories);
@@ -481,6 +602,16 @@ const mergePersistedSettings = (current, changes) => {
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) { if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
additionalApproved.push(changes.homeDirectory); 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 approvedSource = [...baseApproved, ...additionalApproved];
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks) 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 persistSettings = async (changes) => {
const current = await readSettingsFromDisk(); const current = await readSettingsFromDisk();
const sanitized = sanitizeSettingsUpdate(changes); 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); await writeSettingsToDisk(next);
return formatSettingsResponse(next); return formatSettingsResponse(next);
}; };
@@ -551,7 +796,7 @@ const getHmrState = () => {
globalThis[HMR_STATE_KEY] = { globalThis[HMR_STATE_KEY] = {
openCodeProcess: null, openCodeProcess: null,
openCodePort: null, openCodePort: null,
openCodeWorkingDirectory: process.cwd(), openCodeWorkingDirectory: os.homedir(),
isShuttingDown: false, isShuttingDown: false,
signalsAttached: false, signalsAttached: false,
}; };
@@ -2152,6 +2397,10 @@ async function main(options = {}) {
res.flushHeaders(); res.flushHeaders();
} }
const heartbeatInterval = setInterval(() => {
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
}, 30000);
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const reader = upstream.body.getReader(); const reader = upstream.body.getReader();
let buffer = ''; let buffer = '';
@@ -2194,6 +2443,7 @@ async function main(options = {}) {
console.warn('SSE proxy stream error:', error); console.warn('SSE proxy stream error:', error);
} }
} finally { } finally {
clearInterval(heartbeatInterval);
cleanup(); cleanup();
try { try {
res.end(); res.end();
@@ -2220,11 +2470,13 @@ async function main(options = {}) {
return res.status(503).json({ error: 'OpenCode service unavailable' }); 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) const directoryParam = Array.isArray(req.query.directory)
? req.query.directory[0] ? req.query.directory[0]
: req.query.directory; : req.query.directory;
if (typeof directoryParam === 'string' && directoryParam.trim().length > 0) { const resolvedDirectory = headerDirectory || directoryParam || null;
targetUrl.searchParams.set('directory', directoryParam.trim()); if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) {
targetUrl.searchParams.set('directory', resolvedDirectory.trim());
} }
const headers = { const headers = {
@@ -2271,6 +2523,10 @@ async function main(options = {}) {
res.flushHeaders(); res.flushHeaders();
} }
const heartbeatInterval = setInterval(() => {
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
}, 30000);
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const reader = upstream.body.getReader(); const reader = upstream.body.getReader();
let buffer = ''; let buffer = '';
@@ -2313,6 +2569,7 @@ async function main(options = {}) {
console.warn('SSE proxy stream error:', error); console.warn('SSE proxy stream error:', error);
} }
} finally { } finally {
clearInterval(heartbeatInterval);
cleanup(); cleanup();
try { try {
res.end(); res.end();
@@ -2324,7 +2581,7 @@ async function main(options = {}) {
app.get('/api/config/settings', async (_req, res) => { app.get('/api/config/settings', async (_req, res) => {
try { try {
const settings = await readSettingsFromDisk(); const settings = await readSettingsFromDiskMigrated();
res.json(formatSettingsResponse(settings)); res.json(formatSettingsResponse(settings));
} catch (error) { } catch (error) {
console.error('Failed to load settings:', error); console.error('Failed to load settings:', error);
@@ -2345,6 +2602,7 @@ async function main(options = {}) {
const { const {
getAgentSources, getAgentSources,
getAgentScope, getAgentScope,
getAgentConfig,
createAgent, createAgent,
updateAgent, updateAgent,
deleteAgent, deleteAgent,
@@ -2357,16 +2615,23 @@ async function main(options = {}) {
COMMAND_SCOPE COMMAND_SCOPE
} = await import('./lib/opencode-config.js'); } = await import('./lib/opencode-config.js');
app.get('/api/config/agents/:name', (req, res) => { app.get('/api/config/agents/:name', async (req, res) => {
try { try {
const agentName = req.params.name; const agentName = req.params.name;
const workingDirectory = req.query.directory || openCodeWorkingDirectory; const { directory, error } = await resolveProjectDirectory(req);
const sources = getAgentSources(agentName, workingDirectory); 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({ res.json({
name: agentName, name: agentName,
sources: sources, sources: sources,
scope: sources.md.scope, scope,
isBuiltIn: !sources.md.exists && !sources.json.exists isBuiltIn: !sources.md.exists && !sources.json.exists
}); });
} catch (error) { } 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) => { app.post('/api/config/agents/:name', async (req, res) => {
try { try {
const agentName = req.params.name; const agentName = req.params.name;
const { scope, ...config } = req.body; 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] Creating agent:', agentName);
console.log('[Server] Config received:', JSON.stringify(config, null, 2)); 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', { await refreshOpenCodeAfterConfigChange('agent creation', {
agentName agentName
}); });
@@ -2406,13 +2690,16 @@ async function main(options = {}) {
try { try {
const agentName = req.params.name; const agentName = req.params.name;
const updates = req.body; 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] Updating agent: ${agentName}`);
console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); 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'); await refreshOpenCodeAfterConfigChange('agent update');
console.log(`[Server] Agent ${agentName} updated successfully`); console.log(`[Server] Agent ${agentName} updated successfully`);
@@ -2433,9 +2720,12 @@ async function main(options = {}) {
app.delete('/api/config/agents/:name', async (req, res) => { app.delete('/api/config/agents/:name', async (req, res) => {
try { try {
const agentName = req.params.name; 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'); await refreshOpenCodeAfterConfigChange('agent deletion');
res.json({ 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 { try {
const commandName = req.params.name; const commandName = req.params.name;
const workingDirectory = req.query.directory || openCodeWorkingDirectory; const { directory, error } = await resolveProjectDirectory(req);
const sources = getCommandSources(commandName, workingDirectory); 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({ res.json({
name: commandName, name: commandName,
sources: sources, sources: sources,
scope: sources.md.scope, scope,
isBuiltIn: !sources.md.exists && !sources.json.exists isBuiltIn: !sources.md.exists && !sources.json.exists
}); });
} catch (error) { } catch (error) {
@@ -2472,13 +2769,16 @@ async function main(options = {}) {
try { try {
const commandName = req.params.name; const commandName = req.params.name;
const { scope, ...config } = req.body; 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] Creating command:', commandName);
console.log('[Server] Config received:', JSON.stringify(config, null, 2)); 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', { await refreshOpenCodeAfterConfigChange('command creation', {
commandName commandName
}); });
@@ -2499,13 +2799,16 @@ async function main(options = {}) {
try { try {
const commandName = req.params.name; const commandName = req.params.name;
const updates = req.body; 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] Updating command: ${commandName}`);
console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); 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'); await refreshOpenCodeAfterConfigChange('command update');
console.log(`[Server] Command ${commandName} updated successfully`); console.log(`[Server] Command ${commandName} updated successfully`);
@@ -2526,9 +2829,12 @@ async function main(options = {}) {
app.delete('/api/config/commands/:name', async (req, res) => { app.delete('/api/config/commands/:name', async (req, res) => {
try { try {
const commandName = req.params.name; 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'); await refreshOpenCodeAfterConfigChange('command deletion');
res.json({ res.json({
@@ -2559,14 +2865,17 @@ async function main(options = {}) {
} = await import('./lib/opencode-config.js'); } = await import('./lib/opencode-config.js');
// List all discovered skills // List all discovered skills
app.get('/api/config/skills', (req, res) => { app.get('/api/config/skills', async (req, res) => {
try { try {
const workingDirectory = req.query.directory || openCodeWorkingDirectory; const { directory, error } = await resolveProjectDirectory(req);
const skills = discoverSkills(workingDirectory); if (!directory) {
return res.status(400).json({ error });
}
const skills = discoverSkills(directory);
// Enrich with full sources info // Enrich with full sources info
const enrichedSkills = skills.map(skill => { const enrichedSkills = skills.map(skill => {
const sources = getSkillSources(skill.name, workingDirectory); const sources = getSkillSources(skill.name, directory);
return { return {
...skill, ...skill,
sources sources
@@ -2616,7 +2925,10 @@ async function main(options = {}) {
app.get('/api/config/skills/catalog', async (req, res) => { app.get('/api/config/skills/catalog', async (req, res) => {
try { 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 refresh = String(req.query.refresh || '').toLowerCase() === 'true';
const curatedSources = getCuratedSkillsSources(); const curatedSources = getCuratedSkillsSources();
@@ -2634,7 +2946,7 @@ async function main(options = {}) {
const sources = [...curatedSources, ...customSources]; const sources = [...curatedSources, ...customSources];
const discovered = discoverSkills(workingDirectory); const discovered = discoverSkills(directory);
const installedByName = new Map(discovered.map((s) => [s.name, s])); const installedByName = new Map(discovered.map((s) => [s.name, s]));
const itemsBySource = {}; const itemsBySource = {};
@@ -2738,12 +3050,16 @@ async function main(options = {}) {
conflictDecisions, conflictDecisions,
} = req.body || {}; } = req.body || {};
const workingDirectory = req.query.directory; let workingDirectory = null;
if (scope === 'project' && !workingDirectory) { if (scope === 'project') {
return res.status(400).json({ const resolved = await resolveProjectDirectory(req);
ok: false, if (!resolved.directory) {
error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' }, 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); const identity = resolveGitIdentity(gitIdentityId);
@@ -2785,11 +3101,14 @@ async function main(options = {}) {
}); });
// Get single skill sources // Get single skill sources
app.get('/api/config/skills/:name', (req, res) => { app.get('/api/config/skills/:name', async (req, res) => {
try { try {
const skillName = req.params.name; const skillName = req.params.name;
const workingDirectory = req.query.directory || openCodeWorkingDirectory; const { directory, error } = await resolveProjectDirectory(req);
const sources = getSkillSources(skillName, workingDirectory); if (!directory) {
return res.status(400).json({ error });
}
const sources = getSkillSources(skillName, directory);
res.json({ res.json({
name: skillName, name: skillName,
@@ -2805,13 +3124,16 @@ async function main(options = {}) {
}); });
// Get skill supporting file content // 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 { try {
const skillName = req.params.name; const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path 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) { if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' }); return res.status(404).json({ error: 'Skill not found' });
} }
@@ -2833,12 +3155,15 @@ async function main(options = {}) {
try { try {
const skillName = req.params.name; const skillName = req.params.name;
const { scope, ...config } = req.body; 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] 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 // Skills are just files - OpenCode loads them on-demand, no restart needed
res.json({ res.json({
@@ -2857,12 +3182,15 @@ async function main(options = {}) {
try { try {
const skillName = req.params.name; const skillName = req.params.name;
const updates = req.body; 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] 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 // Skills are just files - OpenCode loads them on-demand, no restart needed
res.json({ res.json({
@@ -2882,9 +3210,12 @@ async function main(options = {}) {
const skillName = req.params.name; const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
const { content } = req.body; 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) { if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' }); return res.status(404).json({ error: 'Skill not found' });
} }
@@ -2906,9 +3237,12 @@ async function main(options = {}) {
try { try {
const skillName = req.params.name; const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path 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) { if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' }); 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) => { app.delete('/api/config/skills/:name', async (req, res) => {
try { try {
const skillName = req.params.name; 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 // Skills are just files - OpenCode loads them on-demand, no restart needed
res.json({ res.json({
@@ -3677,40 +4014,41 @@ async function main(options = {}) {
return res.status(400).json({ error: 'Path is required' }); return res.status(400).json({ error: 'Path is required' });
} }
const resolvedPath = path.resolve(normalizeDirectoryPath(requestedPath)); const validated = await validateDirectoryPath(requestedPath);
let stats; if (!validated.ok) {
try { return res.status(400).json({ error: validated.error });
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;
} }
if (!stats.isDirectory()) { const resolvedPath = validated.directory;
return res.status(400).json({ error: 'Specified path is not a 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) { const nextProjects = existing
return res.json({ success: true, restarted: false, path: resolvedPath }); ? existingProjects
} : [
...existingProjects,
{
id: crypto.randomUUID(),
path: resolvedPath,
addedAt: Date.now(),
lastOpenedAt: Date.now(),
},
];
openCodeWorkingDirectory = resolvedPath; const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
syncToHmrState();
await refreshOpenCodeAfterConfigChange('directory change'); const updated = await persistSettings({
projects: nextProjects,
activeProjectId,
lastDirectory: resolvedPath,
});
res.json({ res.json({
success: true, success: true,
restarted: true, restarted: false,
path: resolvedPath path: resolvedPath,
settings: updated,
}); });
} catch (error) { } catch (error) {
console.error('Failed to update OpenCode working directory:', error); console.error('Failed to update OpenCode working directory:', error);
+308 -31
View File
@@ -101,22 +101,72 @@ function getAgentWritePath(agentName, workingDirectory, requestedScope) {
if (existing.path) { if (existing.path) {
return existing; return existing;
} }
// For new agents or built-in overrides: use requested scope or default to user // For new agents or built-in overrides: use requested scope or default to user
const scope = requestedScope || AGENT_SCOPE.USER; const scope = requestedScope || AGENT_SCOPE.USER;
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) { if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
return { return {
scope: AGENT_SCOPE.PROJECT, scope: AGENT_SCOPE.PROJECT,
path: getProjectAgentPath(workingDirectory, agentName) path: getProjectAgentPath(workingDirectory, agentName)
}; };
} }
return { return {
scope: AGENT_SCOPE.USER, scope: AGENT_SCOPE.USER,
path: getUserAgentPath(agentName) 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 ============== // ============== COMMAND SCOPE HELPERS ==============
/** /**
@@ -412,9 +462,125 @@ function writePromptFile(filePath, content) {
console.log(`Updated prompt file: ${filePath}`); 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) { function getProjectConfigPath(workingDirectory) {
if (!workingDirectory) return 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];
}
/**
* 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) { function getConfigPaths(workingDirectory) {
@@ -539,22 +705,23 @@ function getJsonWriteTarget(layers, preferredScope) {
} }
function parseMdFile(filePath) { function parseMdFile(filePath) {
try { const content = fs.readFileSync(filePath, 'utf8');
const content = fs.readFileSync(filePath, 'utf8'); const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) { if (!match) {
return { frontmatter: {}, body: content.trim() }; 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');
} }
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) { function writeMdFile(filePath, frontmatter, body) {
@@ -606,12 +773,6 @@ function getAgentSources(agentName, workingDirectory) {
scope: jsonSource.exists ? jsonScope : null, scope: jsonSource.exists ? jsonScope : null,
fields: [] fields: []
}, },
json: {
exists: jsonSource.exists,
path: jsonPath,
scope: jsonSource.exists ? jsonScope : null,
fields: []
},
// Additional info about both levels // Additional info about both levels
projectMd: { projectMd: {
exists: projectExists, exists: projectExists,
@@ -638,6 +799,48 @@ function getAgentSources(agentName, workingDirectory) {
return sources; 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) { function createAgent(agentName, config, workingDirectory, scope) {
ensureDirs(); ensureDirs();
@@ -693,7 +896,7 @@ function updateAgent(agentName, updates, workingDirectory) {
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0; const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
const jsonTarget = jsonSource.exists const jsonTarget = jsonSource.exists
? { config: jsonSource.config, path: jsonSource.path } ? { config: jsonSource.config, path: jsonSource.path }
: getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER); : getJsonWriteTarget(layers, AGENT_SCOPE.USER);
let config = jsonTarget.config || {}; let config = jsonTarget.config || {};
// Determine if we should create a new md file: // Determine if we should create a new md file:
@@ -742,7 +945,7 @@ function updateAgent(agentName, updates, workingDirectory) {
jsonModified = true; jsonModified = true;
continue; continue;
} }
// For JSON-only agents, store prompt inline in JSON // For JSON-only agents, store prompt inline in JSON
if (!config.agent) config.agent = {}; if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {}; if (!config.agent[agentName]) config.agent[agentName] = {};
@@ -751,9 +954,79 @@ function updateAgent(agentName, updates, workingDirectory) {
continue; 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 inMd = mdData?.frontmatter?.[field] !== undefined;
const inJson = jsonSection?.[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 // JSON takes precedence over md, so update JSON first if field exists there
if (inJson) { if (inJson) {
if (!config.agent) config.agent = {}; if (!config.agent) config.agent = {};
@@ -852,6 +1125,7 @@ function getCommandSources(commandName, workingDirectory) {
const jsonSource = getJsonEntrySource(layers, 'command', commandName); const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const jsonSection = jsonSource.section; const jsonSection = jsonSource.section;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath; 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 = { const sources = {
md: { md: {
@@ -863,6 +1137,7 @@ function getCommandSources(commandName, workingDirectory) {
json: { json: {
exists: jsonSource.exists, exists: jsonSource.exists,
path: jsonPath, path: jsonPath,
scope: jsonSource.exists ? jsonScope : null,
fields: [] fields: []
}, },
// Additional info about both levels // Additional info about both levels
@@ -1373,6 +1648,8 @@ function deleteSkill(skillName, workingDirectory) {
export { export {
getAgentSources, getAgentSources,
getAgentScope, getAgentScope,
getAgentPermissionSource,
getAgentConfig,
createAgent, createAgent,
updateAgent, updateAgent,
deleteAgent, deleteAgent,