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