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:
committed by
GitHub
parent
8aa379e313
commit
18c5b4c7b5
@@ -175,6 +175,47 @@ jobs:
|
|||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Verify macOS entitlements
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUNDLE_DIR="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos"
|
||||||
|
|
||||||
|
if [ ! -d "$BUNDLE_DIR" ]; then
|
||||||
|
echo "Error: bundle directory not found: $BUNDLE_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_PATH=$(find "$BUNDLE_DIR" -maxdepth 2 -name "*.app" -print -quit)
|
||||||
|
if [ -z "$APP_PATH" ]; then
|
||||||
|
echo "Error: .app bundle not found under $BUNDLE_DIR"
|
||||||
|
echo "Contents:"; ls -la "$BUNDLE_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Verifying app bundle: $APP_PATH"
|
||||||
|
codesign -vv "$APP_PATH"
|
||||||
|
|
||||||
|
ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>&1 || true)
|
||||||
|
echo "$ENTITLEMENTS"
|
||||||
|
|
||||||
|
if echo "$ENTITLEMENTS" | grep -q "com.apple.security.app-sandbox"; then
|
||||||
|
echo "Error: app sandbox entitlement is present"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for key in \
|
||||||
|
com.apple.security.cs.allow-jit \
|
||||||
|
com.apple.security.cs.allow-unsigned-executable-memory \
|
||||||
|
com.apple.security.cs.disable-executable-page-protection \
|
||||||
|
com.apple.security.cs.disable-library-validation
|
||||||
|
do
|
||||||
|
if ! echo "$ENTITLEMENTS" | grep -q "<key>$key</key>"; then
|
||||||
|
echo "Error: required entitlement missing: $key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
- name: Prepare release artifacts
|
- name: Prepare release artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
|
|||||||
+7
-2
@@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
- Multi Run / Agent Manager: Select the Agent you want to run for the Worktree sessions
|
- Multi Run / Agent Manager: Select the Agent you want to run for the Worktree sessions (thanks to @wienans).
|
||||||
- Agent Manager now submits the promt, if valid similar to the Chat interfaces
|
- Agent Manager now submits the prompt if valid, similar to the Chat interfaces.
|
||||||
|
- Added worktree management actions in Agent Manager: delete the group or individual worktrees or keep only selected one (thanks to @wienans).
|
||||||
|
- Fixed IME (Input Method Editor) composition handling for CJK input methods, preventing accidental message send during character conversion (thanks to @madebyjun).
|
||||||
|
- Added project management with multi-project support and per-project settings for agents/commands/skills.
|
||||||
|
- Enhanced SSE event stream with heartbeat management, permission bootstrap on connect, and improved reconnection logic.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.3] - 2026-01-04
|
## [1.4.3] - 2026-01-04
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"@heroui/system": "^2.4.23",
|
"@heroui/system": "^2.4.23",
|
||||||
"@heroui/theme": "^2.4.23",
|
"@heroui/theme": "^2.4.23",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@opencode-ai/sdk": "^1.0.209",
|
"@opencode-ai/sdk": "^1.1.1",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@openchamber/desktop",
|
"name": "@openchamber/desktop",
|
||||||
"version": "1.4.2",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
@@ -97,12 +97,15 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.4.2",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@opencode-ai/sdk": "^1.0.209",
|
"@opencode-ai/sdk": "^1.1.1",
|
||||||
"@pierre/diffs": "^1.0.0",
|
"@pierre/diffs": "^1.0.0",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -164,10 +167,10 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.4.2",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.0.209",
|
"@opencode-ai/sdk": "^1.1.1",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
@@ -185,7 +188,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.4.2",
|
"version": "1.4.3",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
@@ -193,7 +196,7 @@
|
|||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@opencode-ai/sdk": "^1.0.209",
|
"@opencode-ai/sdk": "^1.1.1",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -339,6 +342,14 @@
|
|||||||
|
|
||||||
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
||||||
|
|
||||||
|
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||||
|
|
||||||
|
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||||
|
|
||||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||||
|
|
||||||
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
|
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
|
||||||
@@ -519,7 +530,7 @@
|
|||||||
|
|
||||||
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.210", "", {}, "sha512-bTHfV4yGxrPkIZFmQrJwKFTE6m8ztvT3S6/zoFY7KrJ10RSxStLYdYLO5e5gVThEmavARSXGiYlZ9nycou+6rQ=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.1", "", {}, "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ=="],
|
||||||
|
|
||||||
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
|
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@
|
|||||||
"@heroui/system": "^2.4.23",
|
"@heroui/system": "^2.4.23",
|
||||||
"@heroui/theme": "^2.4.23",
|
"@heroui/theme": "^2.4.23",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@opencode-ai/sdk": "^1.0.209",
|
"@opencode-ai/sdk": "^1.1.1",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
Generated
+61
@@ -2146,6 +2146,17 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "json5"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
|
||||||
|
dependencies = [
|
||||||
|
"pest",
|
||||||
|
"pest_derive",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jsonptr"
|
name = "jsonptr"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -2856,6 +2867,7 @@ dependencies = [
|
|||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"json5",
|
||||||
"log",
|
"log",
|
||||||
"nix 0.28.0",
|
"nix 0.28.0",
|
||||||
"objc",
|
"objc",
|
||||||
@@ -2993,6 +3005,49 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pest"
|
||||||
|
version = "2.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"ucd-trie",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pest_derive"
|
||||||
|
version = "2.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed"
|
||||||
|
dependencies = [
|
||||||
|
"pest",
|
||||||
|
"pest_generator",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pest_generator"
|
||||||
|
version = "2.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5"
|
||||||
|
dependencies = [
|
||||||
|
"pest",
|
||||||
|
"pest_meta",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pest_meta"
|
||||||
|
version = "2.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365"
|
||||||
|
dependencies = [
|
||||||
|
"pest",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -5303,6 +5358,12 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ucd-trie"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uds_windows"
|
name = "uds_windows"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ reqwest = { version = "0.12.4", default-features = false, features = ["json", "s
|
|||||||
serde = { version = "1.0.210", features = ["derive"] }
|
serde = { version = "1.0.210", features = ["derive"] }
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
json5 = "0.4"
|
||||||
tauri = { version = "2.9.4", features = ["macos-private-api"] }
|
tauri = { version = "2.9.4", features = ["macos-private-api"] }
|
||||||
tauri-plugin-dialog = "2.4.2"
|
tauri-plugin-dialog = "2.4.2"
|
||||||
tauri-plugin-fs = "2.4.4"
|
tauri-plugin-fs = "2.4.4"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
Intentionally NOT sandboxed. This app is distributed outside the Mac App Store.
|
||||||
|
Do not add com.apple.security.app-sandbox.
|
||||||
|
|
||||||
|
These entitlements are commonly required for WKWebView/WebKit JIT behavior
|
||||||
|
under hardened runtime.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{collections::HashSet, time::Duration};
|
use std::{collections::HashSet, path::PathBuf, time::Duration};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
@@ -11,6 +11,7 @@ use tauri_plugin_notification::NotificationExt;
|
|||||||
use tokio::{io::AsyncBufReadExt, sync::Mutex};
|
use tokio::{io::AsyncBufReadExt, sync::Mutex};
|
||||||
use tokio_util::io::StreamReader;
|
use tokio_util::io::StreamReader;
|
||||||
|
|
||||||
|
use crate::path_utils::expand_tilde_path;
|
||||||
use crate::DesktopRuntime;
|
use crate::DesktopRuntime;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -21,6 +22,14 @@ struct EventEnvelope {
|
|||||||
properties: Value,
|
properties: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MultiplexedEventEnvelope {
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
directory: Option<String>,
|
||||||
|
payload: EventEnvelope,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_assistant_notifications(
|
pub fn spawn_assistant_notifications(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
runtime: DesktopRuntime,
|
runtime: DesktopRuntime,
|
||||||
@@ -71,41 +80,8 @@ async fn run_once(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let prefix = opencode.api_prefix();
|
let prefix = opencode.api_prefix();
|
||||||
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
|
let base = format!("http://127.0.0.1:{port}{prefix}");
|
||||||
|
let response = connect_notifications_sse(runtime, client, &base).await?;
|
||||||
if let Some(dir) = opencode
|
|
||||||
.get_working_directory()
|
|
||||||
.to_str()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
{
|
|
||||||
let mut parsed = reqwest::Url::parse(&url)?;
|
|
||||||
parsed.query_pairs_mut().append_pair("directory", &dir);
|
|
||||||
url = parsed.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("[desktop:notify] Connecting SSE for notifications: {url}");
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(&url)
|
|
||||||
.header("accept", "text/event-stream")
|
|
||||||
.header("accept-encoding", "identity")
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"[desktop:notify] SSE response status={} headers={:?}",
|
|
||||||
response.status(),
|
|
||||||
response.headers()
|
|
||||||
);
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
warn!(
|
|
||||||
"[desktop:notify] SSE connect failed with status {}",
|
|
||||||
response.status()
|
|
||||||
);
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let stream = response
|
let stream = response
|
||||||
.bytes_stream()
|
.bytes_stream()
|
||||||
@@ -142,7 +118,7 @@ async fn run_once(
|
|||||||
let raw = data_lines.join("\n");
|
let raw = data_lines.join("\n");
|
||||||
data_lines.clear();
|
data_lines.clear();
|
||||||
|
|
||||||
match serde_json::from_str::<EventEnvelope>(&raw) {
|
match parse_event_envelope(&raw) {
|
||||||
Ok(event) => handle_event(app, event, notified_messages).await,
|
Ok(event) => handle_event(app, event, notified_messages).await,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
|
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
|
||||||
@@ -159,6 +135,111 @@ async fn run_once(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_event_envelope(raw: &str) -> Result<EventEnvelope> {
|
||||||
|
if let Ok(event) = serde_json::from_str::<EventEnvelope>(raw) {
|
||||||
|
return Ok(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let multiplexed = serde_json::from_str::<MultiplexedEventEnvelope>(raw)?;
|
||||||
|
Ok(multiplexed.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
|
||||||
|
let settings = runtime.settings().load().await.ok()?;
|
||||||
|
|
||||||
|
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
|
||||||
|
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
|
||||||
|
if let Some(path) = projects.iter().find_map(|entry| {
|
||||||
|
let id = entry.get("id").and_then(Value::as_str)?;
|
||||||
|
if id != active_id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
entry.get("path").and_then(Value::as_str)
|
||||||
|
}) {
|
||||||
|
return Some(expand_tilde_path(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings
|
||||||
|
.get("lastDirectory")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(expand_tilde_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_notifications_sse(
|
||||||
|
runtime: &DesktopRuntime,
|
||||||
|
client: &Client,
|
||||||
|
base: &str,
|
||||||
|
) -> Result<reqwest::Response> {
|
||||||
|
let global_url = format!("{base}/global/event");
|
||||||
|
match try_connect_sse(client, &global_url, "[desktop:notify]").await {
|
||||||
|
Ok(response) => {
|
||||||
|
debug!("[desktop:notify] Using SSE endpoint: {global_url}");
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
"[desktop:notify] SSE endpoint unavailable: {global_url} ({err:?}); falling back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_url = format!("{base}/event");
|
||||||
|
match try_connect_sse(client, &event_url, "[desktop:notify]").await {
|
||||||
|
Ok(response) => {
|
||||||
|
debug!("[desktop:notify] Using SSE endpoint: {event_url}");
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
"[desktop:notify] SSE endpoint unavailable: {event_url} ({err:?}); falling back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
|
||||||
|
anyhow::bail!("No project directory available for SSE fallback");
|
||||||
|
};
|
||||||
|
let directory = working_dir.to_string_lossy().to_string();
|
||||||
|
let mut parsed = reqwest::Url::parse(&event_url)?;
|
||||||
|
parsed
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("directory", &directory);
|
||||||
|
let directory_url = parsed.to_string();
|
||||||
|
|
||||||
|
let response = try_connect_sse(client, &directory_url, "[desktop:notify]").await?;
|
||||||
|
debug!("[desktop:notify] Using directory-scoped SSE endpoint: {directory_url}");
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_connect_sse(
|
||||||
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
log_prefix: &str,
|
||||||
|
) -> Result<reqwest::Response> {
|
||||||
|
debug!("{log_prefix} Connecting SSE: {url}");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
|
.header("accept", "text/event-stream")
|
||||||
|
.header("accept-encoding", "identity")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"{log_prefix} SSE response status={} headers={:?}",
|
||||||
|
response.status(),
|
||||||
|
response.headers()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
anyhow::bail!("SSE connect failed with status {}", response.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_event(
|
async fn handle_event(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
event: EventEnvelope,
|
event: EventEnvelope,
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ pub async fn list_directory(
|
|||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
state: tauri::State<'_, DesktopRuntime>,
|
state: tauri::State<'_, DesktopRuntime>,
|
||||||
) -> Result<DirectoryListResult, String> {
|
) -> Result<DirectoryListResult, String> {
|
||||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||||
let resolved_path = resolve_sandboxed_path(path, workspace_root.as_ref())
|
let resolved_path = resolve_sandboxed_path(path, &workspace_roots, default_root.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_list_message())?;
|
.map_err(|err| err.to_list_message())?;
|
||||||
|
|
||||||
@@ -150,10 +150,12 @@ pub async fn list_directory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-check boundary after canonicalization to guard against traversal
|
// Re-check boundary after canonicalization to guard against traversal
|
||||||
if let Some(root) = &workspace_root {
|
if !workspace_roots.is_empty()
|
||||||
if !resolved_path.starts_with(root) {
|
&& !workspace_roots
|
||||||
return Err(FsCommandError::OutsideWorkspace.to_list_message());
|
.iter()
|
||||||
}
|
.any(|root| resolved_path.starts_with(root))
|
||||||
|
{
|
||||||
|
return Err(FsCommandError::OutsideWorkspace.to_list_message());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
@@ -223,8 +225,8 @@ pub async fn search_files(
|
|||||||
max_results: Option<usize>,
|
max_results: Option<usize>,
|
||||||
state: tauri::State<'_, DesktopRuntime>,
|
state: tauri::State<'_, DesktopRuntime>,
|
||||||
) -> Result<SearchFilesResponse, String> {
|
) -> Result<SearchFilesResponse, String> {
|
||||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||||
let resolved_root = resolve_sandboxed_path(directory, workspace_root.as_ref())
|
let resolved_root = resolve_sandboxed_path(directory, &workspace_roots, default_root.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_search_message())?;
|
.map_err(|err| err.to_search_message())?;
|
||||||
|
|
||||||
@@ -352,8 +354,8 @@ pub async fn create_directory(
|
|||||||
return Err("Path is required".to_string());
|
return Err("Path is required".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||||
let resolved_path = resolve_creatable_path(trimmed, workspace_root.as_ref())
|
let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_create_message())?;
|
.map_err(|err| err.to_create_message())?;
|
||||||
|
|
||||||
@@ -369,35 +371,40 @@ pub async fn create_directory(
|
|||||||
|
|
||||||
async fn resolve_sandboxed_path(
|
async fn resolve_sandboxed_path(
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
workspace_root: Option<&PathBuf>,
|
workspace_roots: &[PathBuf],
|
||||||
|
default_root: Option<&PathBuf>,
|
||||||
) -> Result<PathBuf, FsCommandError> {
|
) -> Result<PathBuf, FsCommandError> {
|
||||||
let candidate_input = path
|
let candidate_input = path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|value| value.trim())
|
.map(|value| value.trim())
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
let candidate_path = match (candidate_input, workspace_root) {
|
let fallback_root = default_root
|
||||||
(Some(value), _) => expand_tilde_path(value),
|
.or_else(|| workspace_roots.first())
|
||||||
(None, Some(root)) => root.clone(),
|
.cloned()
|
||||||
(None, None) => default_home_directory(),
|
.unwrap_or_else(default_home_directory);
|
||||||
|
|
||||||
|
let candidate_path = match candidate_input {
|
||||||
|
Some(value) => expand_tilde_path(value),
|
||||||
|
None => fallback_root.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let resolved = if candidate_path.is_absolute() {
|
let resolved = if candidate_path.is_absolute() {
|
||||||
candidate_path
|
candidate_path
|
||||||
} else if let Some(root) = workspace_root {
|
|
||||||
root.join(candidate_path)
|
|
||||||
} else {
|
} else {
|
||||||
default_home_directory().join(candidate_path)
|
fallback_root.join(candidate_path)
|
||||||
};
|
};
|
||||||
|
|
||||||
let canonicalized = fs::canonicalize(&resolved)
|
let canonicalized = fs::canonicalize(&resolved)
|
||||||
.await
|
.await
|
||||||
.map_err(FsCommandError::from)?;
|
.map_err(FsCommandError::from)?;
|
||||||
|
|
||||||
if let Some(root) = workspace_root {
|
if !workspace_roots.is_empty()
|
||||||
if !canonicalized.starts_with(root) {
|
&& !workspace_roots
|
||||||
return Err(FsCommandError::OutsideWorkspace);
|
.iter()
|
||||||
}
|
.any(|root| canonicalized.starts_with(root))
|
||||||
|
{
|
||||||
|
return Err(FsCommandError::OutsideWorkspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(canonicalized)
|
Ok(canonicalized)
|
||||||
@@ -405,19 +412,23 @@ async fn resolve_sandboxed_path(
|
|||||||
|
|
||||||
async fn resolve_creatable_path(
|
async fn resolve_creatable_path(
|
||||||
path: &str,
|
path: &str,
|
||||||
workspace_root: Option<&PathBuf>,
|
workspace_roots: &[PathBuf],
|
||||||
|
default_root: Option<&PathBuf>,
|
||||||
) -> Result<PathBuf, FsCommandError> {
|
) -> Result<PathBuf, FsCommandError> {
|
||||||
let candidate = expand_tilde_path(path);
|
let candidate = expand_tilde_path(path);
|
||||||
if candidate.as_os_str().is_empty() {
|
if candidate.as_os_str().is_empty() {
|
||||||
return Err(FsCommandError::Other("Path is required".to_string()));
|
return Err(FsCommandError::Other("Path is required".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fallback_root = default_root
|
||||||
|
.or_else(|| workspace_roots.first())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(default_home_directory);
|
||||||
|
|
||||||
let absolute = if candidate.is_absolute() {
|
let absolute = if candidate.is_absolute() {
|
||||||
candidate
|
candidate
|
||||||
} else if let Some(root) = workspace_root {
|
|
||||||
root.join(candidate)
|
|
||||||
} else {
|
} else {
|
||||||
default_home_directory().join(candidate)
|
fallback_root.join(candidate)
|
||||||
};
|
};
|
||||||
|
|
||||||
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
|
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
|
||||||
@@ -426,22 +437,79 @@ async fn resolve_creatable_path(
|
|||||||
.await
|
.await
|
||||||
.map_err(FsCommandError::from)?;
|
.map_err(FsCommandError::from)?;
|
||||||
|
|
||||||
if let Some(root) = workspace_root {
|
if !workspace_roots.is_empty()
|
||||||
if !canonical_parent.starts_with(root) {
|
&& !workspace_roots
|
||||||
return Err(FsCommandError::OutsideWorkspace);
|
.iter()
|
||||||
}
|
.any(|root| canonical_parent.starts_with(root))
|
||||||
|
{
|
||||||
|
return Err(FsCommandError::OutsideWorkspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(absolute)
|
Ok(absolute)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_workspace_root(settings: &SettingsStore) -> Option<PathBuf> {
|
async fn resolve_workspace_roots(settings: &SettingsStore) -> (Vec<PathBuf>, Option<PathBuf>) {
|
||||||
if let Ok(Some(last_dir)) = settings.last_directory().await {
|
let mut roots: Vec<PathBuf> = Vec::new();
|
||||||
if let Ok(canonicalized) = fs::canonicalize(&last_dir).await {
|
let mut default_root: Option<PathBuf> = None;
|
||||||
return Some(canonicalized);
|
|
||||||
|
let settings_value = settings.load().await.ok();
|
||||||
|
|
||||||
|
if let Some(value) = settings_value.as_ref() {
|
||||||
|
if let Some(active_id) = value.get("activeProjectId").and_then(|v| v.as_str()) {
|
||||||
|
if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) {
|
||||||
|
if let Some(active_path) = projects.iter().find_map(|entry| {
|
||||||
|
let id = entry.get("id").and_then(|v| v.as_str())?;
|
||||||
|
if id != active_id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
entry.get("path").and_then(|v| v.as_str())
|
||||||
|
}) {
|
||||||
|
if let Ok(canonicalized) =
|
||||||
|
fs::canonicalize(expand_tilde_path(active_path)).await
|
||||||
|
{
|
||||||
|
default_root = Some(canonicalized.clone());
|
||||||
|
roots.push(canonicalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) {
|
||||||
|
for entry in projects {
|
||||||
|
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
|
||||||
|
if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(path)).await {
|
||||||
|
roots.push(canonicalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(last_dir) = value.get("lastDirectory").and_then(|v| v.as_str()) {
|
||||||
|
if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(last_dir)).await {
|
||||||
|
if default_root.is_none() {
|
||||||
|
default_root = Some(canonicalized.clone());
|
||||||
|
}
|
||||||
|
roots.push(canonicalized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
|
||||||
|
if default_root.is_none() {
|
||||||
|
if let Ok(Some(last_dir)) = settings.last_directory().await {
|
||||||
|
if let Ok(canonicalized) = fs::canonicalize(last_dir).await {
|
||||||
|
default_root = Some(canonicalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deduped: Vec<PathBuf> = Vec::new();
|
||||||
|
for root in roots {
|
||||||
|
if !deduped.iter().any(|existing| existing == &root) {
|
||||||
|
deduped.push(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(deduped, default_root)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_home_directory() -> PathBuf {
|
fn default_home_directory() -> PathBuf {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
use chrono::Utc;
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
use crate::path_utils::expand_tilde_path;
|
||||||
use crate::DesktopRuntime;
|
use crate::DesktopRuntime;
|
||||||
@@ -17,6 +20,8 @@ pub struct DirectoryPermissionRequest {
|
|||||||
pub struct DirectoryPermissionResult {
|
pub struct DirectoryPermissionResult {
|
||||||
success: bool,
|
success: bool,
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
project_id: Option<String>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,9 +32,8 @@ pub struct StartAccessingResult {
|
|||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process directory selection from frontend
|
/// Process directory selection from frontend.
|
||||||
/// Updates settings with lastDirectory
|
/// Updates settings (projects, activeProjectId, lastDirectory).
|
||||||
/// OpenCode restart is triggered separately via /api/opencode/directory endpoint
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn process_directory_selection(
|
pub async fn process_directory_selection(
|
||||||
path: String,
|
path: String,
|
||||||
@@ -45,6 +49,7 @@ pub async fn process_directory_selection(
|
|||||||
return Ok(DirectoryPermissionResult {
|
return Ok(DirectoryPermissionResult {
|
||||||
success: false,
|
success: false,
|
||||||
path: None,
|
path: None,
|
||||||
|
project_id: None,
|
||||||
error: Some("Directory does not exist".to_string()),
|
error: Some("Directory does not exist".to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -53,38 +58,94 @@ pub async fn process_directory_selection(
|
|||||||
return Ok(DirectoryPermissionResult {
|
return Ok(DirectoryPermissionResult {
|
||||||
success: false,
|
success: false,
|
||||||
path: None,
|
path: None,
|
||||||
|
project_id: None,
|
||||||
error: Some("Path is not a directory".to_string()),
|
error: Some("Path is not a directory".to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update settings with lastDirectory
|
// Update settings with projects + activeProjectId + lastDirectory
|
||||||
let mut settings = state
|
let now = Utc::now().timestamp_millis();
|
||||||
.settings()
|
let normalized_path_for_update = normalized_path.clone();
|
||||||
.load()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
|
||||||
|
|
||||||
if let Some(obj) = settings.as_object_mut() {
|
let (_, project_id) = state
|
||||||
obj.insert(
|
|
||||||
"lastDirectory".to_string(),
|
|
||||||
serde_json::Value::String(normalized_path.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
state
|
|
||||||
.settings()
|
.settings()
|
||||||
.save(settings)
|
.update_with(|mut settings| {
|
||||||
|
if !settings.is_object() {
|
||||||
|
settings = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_id = {
|
||||||
|
let obj = settings.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
|
||||||
|
if !projects_value.is_array() {
|
||||||
|
*projects_value = json!([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let projects = projects_value.as_array_mut().unwrap();
|
||||||
|
|
||||||
|
let existing_index = projects.iter().position(|entry| {
|
||||||
|
entry
|
||||||
|
.get("path")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(|value| value == normalized_path_for_update)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(index) = existing_index {
|
||||||
|
let entry = projects
|
||||||
|
.get_mut(index)
|
||||||
|
.and_then(|value| value.as_object_mut());
|
||||||
|
if let Some(entry) = entry {
|
||||||
|
entry.insert("lastOpenedAt".to_string(), json!(now));
|
||||||
|
if let Some(id) = entry.get("id").and_then(|value| value.as_str()) {
|
||||||
|
id.to_string()
|
||||||
|
} else {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
entry.insert("id".to_string(), json!(id));
|
||||||
|
id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
projects[index] = json!({
|
||||||
|
"id": id,
|
||||||
|
"path": normalized_path_for_update,
|
||||||
|
"addedAt": now,
|
||||||
|
"lastOpenedAt": now
|
||||||
|
});
|
||||||
|
id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
projects.push(json!({
|
||||||
|
"id": id,
|
||||||
|
"path": normalized_path_for_update,
|
||||||
|
"addedAt": now,
|
||||||
|
"lastOpenedAt": now
|
||||||
|
}));
|
||||||
|
id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(obj) = settings.as_object_mut() {
|
||||||
|
obj.insert("activeProjectId".to_string(), json!(project_id.clone()));
|
||||||
|
obj.insert("lastDirectory".to_string(), json!(normalized_path_for_update));
|
||||||
|
}
|
||||||
|
|
||||||
|
(settings, project_id)
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
|
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"[permissions] Updated settings with lastDirectory: {}",
|
"[permissions] Updated settings with active project {}: {}",
|
||||||
normalized_path
|
project_id, normalized_path
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(DirectoryPermissionResult {
|
Ok(DirectoryPermissionResult {
|
||||||
success: true,
|
success: true,
|
||||||
path: Some(normalized_path),
|
path: Some(normalized_path),
|
||||||
|
project_id: Some(project_id),
|
||||||
error: None,
|
error: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -98,6 +159,7 @@ pub async fn pick_directory(
|
|||||||
Ok(DirectoryPermissionResult {
|
Ok(DirectoryPermissionResult {
|
||||||
success: false,
|
success: false,
|
||||||
path: None,
|
path: None,
|
||||||
|
project_id: None,
|
||||||
error: Some(
|
error: Some(
|
||||||
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
|
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
|
||||||
),
|
),
|
||||||
@@ -122,6 +184,7 @@ pub async fn request_directory_access(
|
|||||||
return Ok(DirectoryPermissionResult {
|
return Ok(DirectoryPermissionResult {
|
||||||
success: false,
|
success: false,
|
||||||
path: None,
|
path: None,
|
||||||
|
project_id: None,
|
||||||
error: Some("Directory does not exist".to_string()),
|
error: Some("Directory does not exist".to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -130,6 +193,7 @@ pub async fn request_directory_access(
|
|||||||
return Ok(DirectoryPermissionResult {
|
return Ok(DirectoryPermissionResult {
|
||||||
success: false,
|
success: false,
|
||||||
path: None,
|
path: None,
|
||||||
|
project_id: None,
|
||||||
error: Some("Path is not a directory".to_string()),
|
error: Some("Path is not a directory".to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -139,11 +203,13 @@ pub async fn request_directory_access(
|
|||||||
Ok(_) => Ok(DirectoryPermissionResult {
|
Ok(_) => Ok(DirectoryPermissionResult {
|
||||||
success: true,
|
success: true,
|
||||||
path: Some(normalized_path),
|
path: Some(normalized_path),
|
||||||
|
project_id: None,
|
||||||
error: None,
|
error: None,
|
||||||
}),
|
}),
|
||||||
Err(e) => Ok(DirectoryPermissionResult {
|
Err(e) => Ok(DirectoryPermissionResult {
|
||||||
success: false,
|
success: false,
|
||||||
path: None,
|
path: None,
|
||||||
|
project_id: None,
|
||||||
error: Some(format!("Cannot access directory: {}", e)),
|
error: Some(format!("Cannot access directory: {}", e)),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use chrono::Utc;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
use crate::path_utils::expand_tilde_path;
|
||||||
use crate::DesktopRuntime;
|
use crate::DesktopRuntime;
|
||||||
@@ -19,52 +21,47 @@ pub struct RestartResult {
|
|||||||
restarted: bool,
|
restarted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load settings from disk (matches Express handler behavior)
|
/// Load settings from disk.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
|
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
|
||||||
let settings = state
|
let (settings, _) = state
|
||||||
.settings()
|
.settings()
|
||||||
.load()
|
.update_with(|mut settings| {
|
||||||
|
migrate_legacy_project_settings(&mut settings);
|
||||||
|
normalize_project_selection(&mut settings);
|
||||||
|
(settings, ())
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||||
|
|
||||||
Ok(SettingsLoadResult {
|
Ok(SettingsLoadResult {
|
||||||
settings,
|
settings: format_settings_response(&settings),
|
||||||
source: "desktop".to_string(),
|
source: "desktop".to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save settings to disk with merge logic matching Express implementation
|
/// Save settings to disk with merge logic.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_settings(
|
pub async fn save_settings(
|
||||||
changes: Value,
|
changes: Value,
|
||||||
state: State<'_, DesktopRuntime>,
|
state: State<'_, DesktopRuntime>,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
// Load current settings
|
|
||||||
let current = state
|
|
||||||
.settings()
|
|
||||||
.load()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to load current settings: {}", e))?;
|
|
||||||
|
|
||||||
// Sanitize incoming changes
|
|
||||||
let sanitized_changes = sanitize_settings_update(&changes);
|
let sanitized_changes = sanitize_settings_update(&changes);
|
||||||
|
|
||||||
// Merge changes into current settings
|
let (merged, _) = state
|
||||||
let merged = merge_persisted_settings(¤t, &sanitized_changes);
|
|
||||||
|
|
||||||
// Save merged settings
|
|
||||||
state
|
|
||||||
.settings()
|
.settings()
|
||||||
.save(merged.clone())
|
.update_with(|current| {
|
||||||
|
let mut merged = merge_persisted_settings(¤t, &sanitized_changes);
|
||||||
|
normalize_project_selection(&mut merged);
|
||||||
|
(merged, ())
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||||
|
|
||||||
// Format response
|
|
||||||
Ok(format_settings_response(&merged))
|
Ok(format_settings_response(&merged))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restart OpenCode CLI (matches Express /api/config/reload)
|
/// Restart the backend process (config reload).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
|
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
|
||||||
state
|
state
|
||||||
@@ -76,6 +73,82 @@ pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<Restar
|
|||||||
Ok(RestartResult { restarted: true })
|
Ok(RestartResult { restarted: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_projects(value: &Value) -> Option<Value> {
|
||||||
|
let arr = value.as_array()?;
|
||||||
|
let mut seen_ids = HashSet::new();
|
||||||
|
let mut seen_paths = HashSet::new();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
for entry in arr {
|
||||||
|
let Some(obj) = entry.as_object() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||||
|
let raw_path = obj
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
if id.is_empty() || raw_path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expanded = expand_tilde_path(raw_path).to_string_lossy().to_string();
|
||||||
|
let normalized = if expanded == "/" {
|
||||||
|
expanded
|
||||||
|
} else {
|
||||||
|
expanded.trim_end_matches('/').replace('\\', "/")
|
||||||
|
};
|
||||||
|
|
||||||
|
if normalized.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen_ids.contains(id) || seen_paths.contains(&normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen_ids.insert(id.to_string());
|
||||||
|
seen_paths.insert(normalized.clone());
|
||||||
|
|
||||||
|
let mut project = serde_json::Map::new();
|
||||||
|
project.insert("id".to_string(), json!(id));
|
||||||
|
project.insert("path".to_string(), json!(normalized));
|
||||||
|
|
||||||
|
if let Some(Value::String(label)) = obj.get("label") {
|
||||||
|
if !label.trim().is_empty() {
|
||||||
|
project.insert("label".to_string(), json!(label.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::Number(num)) = obj.get("addedAt") {
|
||||||
|
if let Some(value) = num.as_i64() {
|
||||||
|
if value >= 0 {
|
||||||
|
project.insert("addedAt".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::Number(num)) = obj.get("lastOpenedAt") {
|
||||||
|
if let Some(value) = num.as_i64() {
|
||||||
|
if value >= 0 {
|
||||||
|
project.insert("lastOpenedAt".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(Value::Object(project));
|
||||||
|
}
|
||||||
|
|
||||||
|
if arr.is_empty() {
|
||||||
|
return Some(Value::Array(vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Value::Array(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
|
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
|
||||||
fn sanitize_settings_update(payload: &Value) -> Value {
|
fn sanitize_settings_update(payload: &Value) -> Value {
|
||||||
let mut result = json!({});
|
let mut result = json!({});
|
||||||
@@ -116,6 +189,14 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
result_obj.insert("homeDirectory".to_string(), json!(expanded));
|
result_obj.insert("homeDirectory".to_string(), json!(expanded));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(projects) = obj.get("projects").and_then(sanitize_projects) {
|
||||||
|
result_obj.insert("projects".to_string(), projects);
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("activeProjectId") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result_obj.insert("activeProjectId".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(Value::String(s)) = obj.get("uiFont") {
|
if let Some(Value::String(s)) = obj.get("uiFont") {
|
||||||
if !s.is_empty() {
|
if !s.is_empty() {
|
||||||
result_obj.insert("uiFont".to_string(), json!(s));
|
result_obj.insert("uiFont".to_string(), json!(s));
|
||||||
@@ -259,6 +340,135 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_legacy_project_settings(settings: &mut Value) {
|
||||||
|
if !settings.is_object() {
|
||||||
|
*settings = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now().timestamp_millis();
|
||||||
|
let obj = settings.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
let has_projects = obj
|
||||||
|
.get("projects")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|arr| !arr.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_projects {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_directory = obj
|
||||||
|
.get("lastDirectory")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(expand_tilde_path);
|
||||||
|
|
||||||
|
let Some(mut last_directory) = last_directory else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(canonicalized) = std::fs::canonicalize(&last_directory) {
|
||||||
|
last_directory = canonicalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(stats) = std::fs::metadata(&last_directory) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !stats.is_dir() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_path = last_directory.to_string_lossy().to_string();
|
||||||
|
if normalized_path.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_id = Uuid::new_v4().to_string();
|
||||||
|
let active_project_id = project_id.clone();
|
||||||
|
let project_path = normalized_path.clone();
|
||||||
|
|
||||||
|
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
|
||||||
|
*projects_value = json!([
|
||||||
|
{
|
||||||
|
"id": project_id,
|
||||||
|
"path": project_path,
|
||||||
|
"addedAt": now,
|
||||||
|
"lastOpenedAt": now
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
obj.insert("activeProjectId".to_string(), json!(active_project_id));
|
||||||
|
|
||||||
|
// Ensure approvedDirectories includes the migrated project root.
|
||||||
|
let approved_value = obj
|
||||||
|
.entry("approvedDirectories")
|
||||||
|
.or_insert_with(|| json!([]));
|
||||||
|
if !approved_value.is_array() {
|
||||||
|
*approved_value = json!([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(array) = approved_value.as_array_mut() {
|
||||||
|
array.push(json!(normalized_path.clone()));
|
||||||
|
array.retain(|entry| entry.as_str().is_some_and(|value| !value.trim().is_empty()));
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
array.retain(|entry| {
|
||||||
|
let Some(value) = entry.as_str() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if seen.contains(value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.insert(value.to_string());
|
||||||
|
true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_project_selection(settings: &mut Value) {
|
||||||
|
let Some(obj) = settings.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(projects) = obj.get("projects").and_then(|value| value.as_array()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if projects.is_empty() {
|
||||||
|
obj.remove("activeProjectId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_active = obj
|
||||||
|
.get("activeProjectId")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let has_active = projects.iter().any(|entry| {
|
||||||
|
entry
|
||||||
|
.get("id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(|id| id == current_active)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
if has_active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_id = projects
|
||||||
|
.first()
|
||||||
|
.and_then(|entry| entry.get("id"))
|
||||||
|
.and_then(|value| value.as_str());
|
||||||
|
|
||||||
|
if let Some(id) = first_id {
|
||||||
|
obj.insert("activeProjectId".to_string(), json!(id));
|
||||||
|
} else {
|
||||||
|
obj.remove("activeProjectId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge persisted settings (port of Express mergePersistedSettings)
|
/// Merge persisted settings (port of Express mergePersistedSettings)
|
||||||
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
|
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
|
||||||
let mut result = current.clone();
|
let mut result = current.clone();
|
||||||
@@ -290,6 +500,22 @@ fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let project_source = if let Some(Value::Array(arr)) = changes_obj.get("projects") {
|
||||||
|
Some(arr)
|
||||||
|
} else {
|
||||||
|
current.get("projects").and_then(|v| v.as_array())
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(entries) = project_source {
|
||||||
|
for entry in entries {
|
||||||
|
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
|
||||||
|
if !path.trim().is_empty() {
|
||||||
|
additional_approved.push(path.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
|
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
|
||||||
for item in additional_approved {
|
for item in additional_approved {
|
||||||
approved_set.insert(item);
|
approved_set.insert(item);
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ use anyhow::{anyhow, Result};
|
|||||||
use assistant_notifications::spawn_assistant_notifications;
|
use assistant_notifications::spawn_assistant_notifications;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::{to_bytes, Body},
|
body::{to_bytes, Body},
|
||||||
extract::{OriginalUri, State},
|
extract::{Request, State},
|
||||||
http::{Method, Request, Response, StatusCode},
|
http::{Method, StatusCode},
|
||||||
response::IntoResponse,
|
response::{IntoResponse, Response},
|
||||||
routing::{any, get, post},
|
routing::{any, get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
@@ -39,6 +39,7 @@ use commands::git::{
|
|||||||
update_git_identity,
|
update_git_identity,
|
||||||
};
|
};
|
||||||
use commands::logs::fetch_desktop_logs;
|
use commands::logs::fetch_desktop_logs;
|
||||||
|
|
||||||
use commands::notifications::desktop_notify;
|
use commands::notifications::desktop_notify;
|
||||||
use commands::permissions::{
|
use commands::permissions::{
|
||||||
pick_directory, process_directory_selection, request_directory_access,
|
pick_directory, process_directory_selection, request_directory_access,
|
||||||
@@ -157,10 +158,7 @@ pub(crate) struct DesktopRuntime {
|
|||||||
impl DesktopRuntime {
|
impl DesktopRuntime {
|
||||||
fn initialize_sync() -> Result<Self> {
|
fn initialize_sync() -> Result<Self> {
|
||||||
let settings = Arc::new(SettingsStore::new()?);
|
let settings = Arc::new(SettingsStore::new()?);
|
||||||
let initial_dir = tauri::async_runtime::block_on(settings.last_directory())
|
let opencode = Arc::new(OpenCodeManager::new_with_directory(None));
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
let opencode = Arc::new(OpenCodeManager::new_with_directory(initial_dir.clone()));
|
|
||||||
|
|
||||||
let client = Client::builder().build()?;
|
let client = Client::builder().build()?;
|
||||||
|
|
||||||
@@ -170,6 +168,7 @@ impl DesktopRuntime {
|
|||||||
let server_state = ServerState {
|
let server_state = ServerState {
|
||||||
client,
|
client,
|
||||||
opencode: opencode.clone(),
|
opencode: opencode.clone(),
|
||||||
|
settings: settings.clone(),
|
||||||
server_port,
|
server_port,
|
||||||
directory_change_lock: Arc::new(Mutex::new(())),
|
directory_change_lock: Arc::new(Mutex::new(())),
|
||||||
models_metadata_cache: Arc::new(Mutex::new(ModelsMetadataCache::default())),
|
models_metadata_cache: Arc::new(Mutex::new(ModelsMetadataCache::default())),
|
||||||
@@ -217,6 +216,7 @@ impl DesktopRuntime {
|
|||||||
struct ServerState {
|
struct ServerState {
|
||||||
client: Client,
|
client: Client,
|
||||||
opencode: Arc<OpenCodeManager>,
|
opencode: Arc<OpenCodeManager>,
|
||||||
|
settings: Arc<SettingsStore>,
|
||||||
server_port: u16,
|
server_port: u16,
|
||||||
directory_change_lock: Arc<Mutex<()>>,
|
directory_change_lock: Arc<Mutex<()>>,
|
||||||
models_metadata_cache: Arc<Mutex<ModelsMetadataCache>>,
|
models_metadata_cache: Arc<Mutex<ModelsMetadataCache>>,
|
||||||
@@ -420,21 +420,11 @@ fn build_macos_menu<R: tauri::Runtime>(
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
// View menu items
|
// View menu items
|
||||||
let open_git_tab = MenuItem::with_id(
|
let open_git_tab =
|
||||||
app,
|
MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Cmd+G"))?;
|
||||||
MENU_ITEM_OPEN_GIT_TAB_ID,
|
|
||||||
"Git",
|
|
||||||
true,
|
|
||||||
Some("Cmd+G"),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let open_diff_tab = MenuItem::with_id(
|
let open_diff_tab =
|
||||||
app,
|
MenuItem::with_id(app, MENU_ITEM_OPEN_DIFF_TAB_ID, "Diff", true, Some("Cmd+E"))?;
|
||||||
MENU_ITEM_OPEN_DIFF_TAB_ID,
|
|
||||||
"Diff",
|
|
||||||
true,
|
|
||||||
Some("Cmd+E"),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let open_terminal_tab = MenuItem::with_id(
|
let open_terminal_tab = MenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
@@ -718,18 +708,8 @@ fn main() {
|
|||||||
|
|
||||||
let app_handle = app.app_handle().clone();
|
let app_handle = app.app_handle().clone();
|
||||||
let runtime_clone = runtime.clone();
|
let runtime_clone = runtime.clone();
|
||||||
let has_initial_dir =
|
|
||||||
tauri::async_runtime::block_on(runtime.settings().last_directory())
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.is_some();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
// Only start opencode if we have a saved directory, otherwise frontend will prompt
|
runtime_clone.start_opencode().await;
|
||||||
if has_initial_dir {
|
|
||||||
runtime_clone.start_opencode().await;
|
|
||||||
} else {
|
|
||||||
info!("[desktop] No saved directory - waiting for user to select one");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await
|
restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await
|
||||||
@@ -1190,11 +1170,11 @@ struct DirectoryChangeResponse {
|
|||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_response<T: Serialize>(status: StatusCode, payload: T) -> Response<Body> {
|
fn json_response<T: Serialize>(status: StatusCode, payload: T) -> Response {
|
||||||
(status, Json(payload)).into_response()
|
(status, Json(payload)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response<Body> {
|
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response {
|
||||||
json_response(
|
json_response(
|
||||||
status,
|
status,
|
||||||
ConfigErrorResponse {
|
ConfigErrorResponse {
|
||||||
@@ -1203,10 +1183,8 @@ fn config_error_response(status: StatusCode, message: impl Into<String>) -> Resp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn parse_request_payload(
|
async fn parse_request_payload(req: &mut Request) -> Result<HashMap<String, Value>, Response> {
|
||||||
req: Request<Body>,
|
let body = std::mem::take(req.body_mut());
|
||||||
) -> Result<HashMap<String, Value>, Response<Body>> {
|
|
||||||
let (_, body) = req.into_parts();
|
|
||||||
let body_bytes = to_bytes(body, PROXY_BODY_LIMIT)
|
let body_bytes = to_bytes(body, PROXY_BODY_LIMIT)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Invalid request body"))?;
|
.map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Invalid request body"))?;
|
||||||
@@ -1222,7 +1200,7 @@ async fn parse_request_payload(
|
|||||||
async fn refresh_opencode_after_config_change(
|
async fn refresh_opencode_after_config_change(
|
||||||
state: &ServerState,
|
state: &ServerState,
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<(), Response<Body>> {
|
) -> Result<(), Response> {
|
||||||
info!("[desktop:config] Restarting OpenCode after {}", reason);
|
info!("[desktop:config] Restarting OpenCode after {}", reason);
|
||||||
state.opencode.restart().await.map_err(|err| {
|
state.opencode.restart().await.map_err(|err| {
|
||||||
config_error_response(
|
config_error_response(
|
||||||
@@ -1233,21 +1211,146 @@ async fn refresh_opencode_after_config_change(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_directory_from_request(req: &Request) -> Option<String> {
|
||||||
|
if let Some(value) = req.headers().get("x-opencode-directory") {
|
||||||
|
if let Ok(text) = value.to_str() {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = req.uri().query()?;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let mut parts = pair.splitn(2, '=');
|
||||||
|
let key = parts.next()?;
|
||||||
|
if key != "directory" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value = parts.next().unwrap_or("");
|
||||||
|
if let Ok(decoded) = urlencoding::decode(value) {
|
||||||
|
let trimmed = decoded.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_directory_candidate(candidate: &str) -> Result<PathBuf, Response> {
|
||||||
|
let mut resolved = expand_tilde_path(candidate);
|
||||||
|
if !resolved.is_absolute() {
|
||||||
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
resolved = home.join(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = fs::metadata(&resolved)
|
||||||
|
.await
|
||||||
|
.map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Directory not found"))?;
|
||||||
|
if !metadata.is_dir() {
|
||||||
|
return Err(config_error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Specified path is not a directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(canonicalized) = fs::canonicalize(&resolved).await {
|
||||||
|
resolved = canonicalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_project_directory_from_settings(
|
||||||
|
settings: &SettingsStore,
|
||||||
|
) -> Result<Option<PathBuf>, Response> {
|
||||||
|
let raw = settings.load().await.map_err(|_| {
|
||||||
|
config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load settings")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let active_id = raw
|
||||||
|
.get("activeProjectId")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let projects = raw.get("projects").and_then(|value| value.as_array());
|
||||||
|
if let Some(projects) = projects {
|
||||||
|
if let Some(entry) = projects.iter().find(|entry| {
|
||||||
|
entry
|
||||||
|
.get("id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(|value| value.trim() == active_id)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}) {
|
||||||
|
if let Some(path) = entry.get("path").and_then(|value| value.as_str()) {
|
||||||
|
return resolve_directory_candidate(path).await.map(Some);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(entry) = projects.first() {
|
||||||
|
if let Some(path) = entry.get("path").and_then(|value| value.as_str()) {
|
||||||
|
return resolve_directory_candidate(path).await.map(Some);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let legacy = raw
|
||||||
|
.get("lastDirectory")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
if !legacy.is_empty() {
|
||||||
|
return resolve_directory_candidate(legacy).await.map(Some);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_project_directory(
|
||||||
|
state: &ServerState,
|
||||||
|
directory: Option<String>,
|
||||||
|
) -> Result<PathBuf, Response> {
|
||||||
|
if let Some(directory) = directory {
|
||||||
|
return resolve_directory_candidate(&directory).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
match resolve_project_directory_from_settings(state.settings.as_ref()).await? {
|
||||||
|
Some(path) => Ok(path),
|
||||||
|
None => Err(config_error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Directory parameter or active project is required",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_agent_route(
|
async fn handle_agent_route(
|
||||||
state: &ServerState,
|
state: &ServerState,
|
||||||
method: Method,
|
method: Method,
|
||||||
req: Request<Body>,
|
mut req: Request,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Result<Response<Body>, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
// Get working directory for project-level agent detection
|
// Get working directory for project-level agent detection
|
||||||
let working_directory = state.opencode.get_working_directory();
|
let working_directory =
|
||||||
|
match resolve_project_directory(state, extract_directory_from_request(&req)).await {
|
||||||
|
Ok(directory) => directory,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
match method {
|
match method {
|
||||||
Method::GET => {
|
Method::GET => {
|
||||||
match opencode_config::get_agent_sources(&name, Some(&working_directory)).await {
|
match opencode_config::get_agent_sources(&name, Some(&working_directory)).await {
|
||||||
Ok(sources) => {
|
Ok(sources) => {
|
||||||
let scope = sources.md.scope.clone().map(|s| match s {
|
let resolved_scope = if sources.md.exists {
|
||||||
|
sources.md.scope.clone()
|
||||||
|
} else {
|
||||||
|
sources.json.scope.clone()
|
||||||
|
};
|
||||||
|
let scope = resolved_scope.map(|s| match s {
|
||||||
opencode_config::Scope::User => opencode_config::CommandScope::User,
|
opencode_config::Scope::User => opencode_config::CommandScope::User,
|
||||||
opencode_config::Scope::Project => opencode_config::CommandScope::Project,
|
opencode_config::Scope::Project => opencode_config::CommandScope::Project,
|
||||||
});
|
});
|
||||||
@@ -1271,12 +1374,11 @@ async fn handle_agent_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::POST => {
|
Method::POST => {
|
||||||
let payload = match parse_request_payload(req).await {
|
let payload = match parse_request_payload(&mut req).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Extract scope from payload if present
|
// Extract scope from payload if present
|
||||||
let scope = payload
|
let scope = payload
|
||||||
.get("scope")
|
.get("scope")
|
||||||
@@ -1320,7 +1422,7 @@ async fn handle_agent_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::PATCH => {
|
Method::PATCH => {
|
||||||
let payload = match parse_request_payload(req).await {
|
let payload = match parse_request_payload(&mut req).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
@@ -1421,11 +1523,17 @@ struct SkillFileResponse {
|
|||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>, StatusCode> {
|
async fn handle_skill_list_route(
|
||||||
let working_directory = state.opencode.get_working_directory();
|
state: &ServerState,
|
||||||
|
req: Request,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let working_directory =
|
||||||
|
match resolve_project_directory(state, extract_directory_from_request(&req)).await {
|
||||||
|
Ok(directory) => directory,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
let discovered = opencode_config::discover_skills(Some(&working_directory));
|
let discovered = opencode_config::discover_skills(Some(&working_directory));
|
||||||
|
|
||||||
|
|
||||||
let mut skills = Vec::new();
|
let mut skills = Vec::new();
|
||||||
for skill in discovered {
|
for skill in discovered {
|
||||||
match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await {
|
match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await {
|
||||||
@@ -1447,18 +1555,24 @@ async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(json_response(StatusCode::OK, serde_json::json!({ "skills": skills })))
|
Ok(json_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
serde_json::json!({ "skills": skills }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_skill_route(
|
async fn handle_skill_route(
|
||||||
state: &ServerState,
|
state: &ServerState,
|
||||||
method: Method,
|
method: Method,
|
||||||
req: Request<Body>,
|
mut req: Request,
|
||||||
name: String,
|
name: String,
|
||||||
file_path: Option<String>,
|
file_path: Option<String>,
|
||||||
) -> Result<Response<Body>, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let working_directory = state.opencode.get_working_directory();
|
let working_directory =
|
||||||
|
match resolve_project_directory(state, extract_directory_from_request(&req)).await {
|
||||||
|
Ok(directory) => directory,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
// Handle file operations: /api/config/skills/:name/files/*
|
// Handle file operations: /api/config/skills/:name/files/*
|
||||||
if let Some(ref fp) = file_path {
|
if let Some(ref fp) = file_path {
|
||||||
@@ -1504,11 +1618,14 @@ async fn handle_skill_route(
|
|||||||
}
|
}
|
||||||
Method::PUT => {
|
Method::PUT => {
|
||||||
// Write supporting file
|
// Write supporting file
|
||||||
let payload = match parse_request_payload(req).await {
|
let payload = match parse_request_payload(&mut req).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
let content = payload
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
||||||
Ok(sources) => {
|
Ok(sources) => {
|
||||||
@@ -1616,12 +1733,13 @@ async fn handle_skill_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::POST => {
|
Method::POST => {
|
||||||
let payload = match parse_request_payload(req).await {
|
let payload = match parse_request_payload(&mut req).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
|
|
||||||
let scope = payload.get("scope")
|
let scope = payload
|
||||||
|
.get("scope")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| match s {
|
.and_then(|s| match s {
|
||||||
"project" => Some(opencode_config::SkillScope::Project),
|
"project" => Some(opencode_config::SkillScope::Project),
|
||||||
@@ -1667,7 +1785,7 @@ async fn handle_skill_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::PATCH => {
|
Method::PATCH => {
|
||||||
let payload = match parse_request_payload(req).await {
|
let payload = match parse_request_payload(&mut req).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
@@ -1744,18 +1862,26 @@ async fn handle_skill_route(
|
|||||||
async fn handle_command_route(
|
async fn handle_command_route(
|
||||||
state: &ServerState,
|
state: &ServerState,
|
||||||
method: Method,
|
method: Method,
|
||||||
req: Request<Body>,
|
mut req: Request,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Result<Response<Body>, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
// Get working directory for project-level command detection
|
// Get working directory for project-level command detection
|
||||||
let working_directory = state.opencode.get_working_directory();
|
let working_directory =
|
||||||
|
match resolve_project_directory(state, extract_directory_from_request(&req)).await {
|
||||||
|
Ok(directory) => directory,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
match method {
|
match method {
|
||||||
Method::GET => {
|
Method::GET => {
|
||||||
match opencode_config::get_command_sources(&name, Some(&working_directory)).await {
|
match opencode_config::get_command_sources(&name, Some(&working_directory)).await {
|
||||||
Ok(sources) => {
|
Ok(sources) => {
|
||||||
let scope = sources.md.scope.clone().map(|s| match s {
|
let resolved_scope = if sources.md.exists {
|
||||||
|
sources.md.scope.clone()
|
||||||
|
} else {
|
||||||
|
sources.json.scope.clone()
|
||||||
|
};
|
||||||
|
let scope = resolved_scope.map(|s| match s {
|
||||||
opencode_config::Scope::User => opencode_config::CommandScope::User,
|
opencode_config::Scope::User => opencode_config::CommandScope::User,
|
||||||
opencode_config::Scope::Project => opencode_config::CommandScope::Project,
|
opencode_config::Scope::Project => opencode_config::CommandScope::Project,
|
||||||
});
|
});
|
||||||
@@ -1779,12 +1905,11 @@ async fn handle_command_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::POST => {
|
Method::POST => {
|
||||||
let payload = match parse_request_payload(req).await {
|
let payload = match parse_request_payload(&mut req).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Extract scope from payload if present
|
// Extract scope from payload if present
|
||||||
let scope = payload
|
let scope = payload
|
||||||
.get("scope")
|
.get("scope")
|
||||||
@@ -1831,7 +1956,7 @@ async fn handle_command_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::PATCH => {
|
Method::PATCH => {
|
||||||
let payload = match parse_request_payload(req).await {
|
let payload = match parse_request_payload(&mut req).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
@@ -1913,8 +2038,8 @@ async fn handle_config_routes(
|
|||||||
state: ServerState,
|
state: ServerState,
|
||||||
path: &str,
|
path: &str,
|
||||||
method: Method,
|
method: Method,
|
||||||
req: Request<Body>,
|
mut req: Request,
|
||||||
) -> Result<Response<Body>, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
if let Some(name) = path.strip_prefix("/api/config/agents/") {
|
if let Some(name) = path.strip_prefix("/api/config/agents/") {
|
||||||
let trimmed = name.trim();
|
let trimmed = name.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -1945,13 +2070,17 @@ async fn handle_config_routes(
|
|||||||
.map(|q| q.contains("refresh=true"))
|
.map(|q| q.contains("refresh=true"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let working_directory = state.opencode.get_working_directory();
|
let working_directory =
|
||||||
|
match resolve_project_directory(&state, extract_directory_from_request(&req)).await {
|
||||||
|
Ok(directory) => directory,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
let payload = skills_catalog::get_catalog(&working_directory, refresh).await;
|
let payload = skills_catalog::get_catalog(&working_directory, refresh).await;
|
||||||
return Ok(json_response(StatusCode::OK, payload));
|
return Ok(json_response(StatusCode::OK, payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
if path == "/api/config/skills/scan" && method == Method::POST {
|
if path == "/api/config/skills/scan" && method == Method::POST {
|
||||||
let payload_map = match parse_request_payload(req).await {
|
let payload_map = match parse_request_payload(&mut req).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
@@ -1991,7 +2120,7 @@ async fn handle_config_routes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if path == "/api/config/skills/install" && method == Method::POST {
|
if path == "/api/config/skills/install" && method == Method::POST {
|
||||||
let payload_map = match parse_request_payload(req).await {
|
let payload_map = match parse_request_payload(&mut req).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
@@ -2019,15 +2148,22 @@ async fn handle_config_routes(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let working_directory = state.opencode.get_working_directory();
|
let working_directory = if install_request.scope == "project" {
|
||||||
let response = skills_catalog::install_skills(&working_directory, install_request).await;
|
match resolve_project_directory(&state, extract_directory_from_request(&req)).await {
|
||||||
|
Ok(directory) => directory,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = skills_catalog::install_skills(&working_directory, install_request).await;
|
||||||
let status = if response.ok {
|
let status = if response.ok {
|
||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") {
|
|
||||||
StatusCode::CONFLICT
|
|
||||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") {
|
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") {
|
||||||
StatusCode::UNAUTHORIZED
|
StatusCode::UNAUTHORIZED
|
||||||
|
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") {
|
||||||
|
StatusCode::CONFLICT
|
||||||
} else {
|
} else {
|
||||||
StatusCode::BAD_REQUEST
|
StatusCode::BAD_REQUEST
|
||||||
};
|
};
|
||||||
@@ -2037,7 +2173,7 @@ async fn handle_config_routes(
|
|||||||
|
|
||||||
// Handle skill routes: /api/config/skills and /api/config/skills/:name
|
// Handle skill routes: /api/config/skills and /api/config/skills/:name
|
||||||
if path == "/api/config/skills" && method == Method::GET {
|
if path == "/api/config/skills" && method == Method::GET {
|
||||||
return handle_skill_list_route(&state).await;
|
return handle_skill_list_route(&state, req).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(rest) = path.strip_prefix("/api/config/skills/") {
|
if let Some(rest) = path.strip_prefix("/api/config/skills/") {
|
||||||
@@ -2059,7 +2195,6 @@ async fn handle_config_routes(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let trimmed = rest.trim();
|
let trimmed = rest.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Ok(config_error_response(
|
return Ok(config_error_response(
|
||||||
@@ -2158,7 +2293,8 @@ async fn change_directory_handler(
|
|||||||
|
|
||||||
let mut resolved_path = expand_tilde_path(requested_path);
|
let mut resolved_path = expand_tilde_path(requested_path);
|
||||||
if !resolved_path.is_absolute() {
|
if !resolved_path.is_absolute() {
|
||||||
resolved_path = state.opencode.get_working_directory().join(resolved_path);
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
resolved_path = home.join(resolved_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate directory exists and is accessible
|
// Validate directory exists and is accessible
|
||||||
@@ -2185,51 +2321,72 @@ async fn change_directory_handler(
|
|||||||
resolved_path = canonicalized;
|
resolved_path = canonicalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
let current_dir = state.opencode.get_working_directory();
|
let path_value = resolved_path.to_string_lossy().to_string();
|
||||||
let is_running = state.opencode.current_port().is_some();
|
|
||||||
|
|
||||||
// If already on this directory and OpenCode is running, no restart needed
|
|
||||||
if current_dir == resolved_path && is_running {
|
|
||||||
return Ok(Json(DirectoryChangeResponse {
|
|
||||||
success: true,
|
|
||||||
restarted: false,
|
|
||||||
path: resolved_path.to_string_lossy().to_string(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("[desktop:http] Changing directory to {:?}", resolved_path);
|
|
||||||
|
|
||||||
// Update working directory and restart OpenCode
|
|
||||||
state
|
state
|
||||||
.opencode
|
.settings
|
||||||
.set_working_directory(resolved_path.clone())
|
.update(|mut settings| {
|
||||||
.await
|
if !settings.is_object() {
|
||||||
.map_err(|e| {
|
settings = Value::Object(Default::default());
|
||||||
error!(
|
}
|
||||||
"[desktop:http] ERROR: Failed to set working directory: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
state.opencode.restart().await.map_err(|e| {
|
let mut projects = settings
|
||||||
error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e);
|
.get("projects")
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
.and_then(|value| value.as_array())
|
||||||
})?;
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let existing_index = projects.iter().position(|entry| {
|
||||||
|
entry
|
||||||
|
.get("path")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(|value| value == path_value)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
let active_project_id = if let Some(index) = existing_index {
|
||||||
|
projects[index]
|
||||||
|
.get("id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let project = serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"path": path_value,
|
||||||
|
"addedAt": chrono::Utc::now().timestamp_millis(),
|
||||||
|
"lastOpenedAt": chrono::Utc::now().timestamp_millis(),
|
||||||
|
});
|
||||||
|
projects.push(project);
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
|
let map = settings.as_object_mut().unwrap();
|
||||||
|
map.insert("projects".to_string(), Value::Array(projects));
|
||||||
|
map.insert(
|
||||||
|
"activeProjectId".to_string(),
|
||||||
|
Value::String(active_project_id.clone()),
|
||||||
|
);
|
||||||
|
map.insert("lastDirectory".to_string(), Value::String(path_value.clone()));
|
||||||
|
|
||||||
|
settings
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
Ok(Json(DirectoryChangeResponse {
|
Ok(Json(DirectoryChangeResponse {
|
||||||
success: true,
|
success: true,
|
||||||
restarted: true,
|
restarted: false,
|
||||||
path: resolved_path.to_string_lossy().to_string(),
|
path: path_value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn proxy_to_opencode(
|
async fn proxy_to_opencode(
|
||||||
State(state): State<ServerState>,
|
State(state): State<ServerState>,
|
||||||
original: OriginalUri,
|
req: Request,
|
||||||
req: Request<Body>,
|
) -> Result<Response, axum::http::StatusCode> {
|
||||||
) -> Result<Response<Body>, StatusCode> {
|
let origin_path = req.uri().path().to_string();
|
||||||
let origin_path = original.0.path().to_string();
|
|
||||||
let method = req.method().clone();
|
let method = req.method().clone();
|
||||||
|
|
||||||
// Check if this is a provider auth deletion request (DELETE /api/provider/:id/auth)
|
// Check if this is a provider auth deletion request (DELETE /api/provider/:id/auth)
|
||||||
@@ -2253,7 +2410,7 @@ async fn proxy_to_opencode(
|
|||||||
StatusCode::SERVICE_UNAVAILABLE
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let query = original.0.query();
|
let query = req.uri().query();
|
||||||
let rewritten_path = state.opencode.rewrite_path(&origin_path);
|
let rewritten_path = state.opencode.rewrite_path(&origin_path);
|
||||||
let mut target = format!("http://127.0.0.1:{port}{rewritten_path}");
|
let mut target = format!("http://127.0.0.1:{port}{rewritten_path}");
|
||||||
if let Some(q) = query {
|
if let Some(q) = query {
|
||||||
@@ -2351,14 +2508,41 @@ impl SettingsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn save(&self, payload: Value) -> Result<()> {
|
|
||||||
|
pub(crate) async fn update_with<R, F>(&self, f: F) -> Result<(Value, R)>
|
||||||
|
where
|
||||||
|
F: FnOnce(Value) -> (Value, R),
|
||||||
|
{
|
||||||
let _lock = self.guard.lock().await;
|
let _lock = self.guard.lock().await;
|
||||||
if let Some(parent) = self.path.parent() {
|
|
||||||
fs::create_dir_all(parent).await.ok();
|
let current = match fs::read(&self.path).await {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
Value::Object(Default::default())
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_snapshot = current.clone();
|
||||||
|
let (next, result) = f(current);
|
||||||
|
|
||||||
|
if next != current_snapshot {
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
fs::create_dir_all(parent).await.ok();
|
||||||
|
}
|
||||||
|
let bytes = serde_json::to_vec_pretty(&next)?;
|
||||||
|
fs::write(&self.path, bytes).await?;
|
||||||
}
|
}
|
||||||
let bytes = serde_json::to_vec_pretty(&payload)?;
|
|
||||||
fs::write(&self.path, bytes).await?;
|
Ok((next, result))
|
||||||
Ok(())
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn update<F>(&self, f: F) -> Result<Value>
|
||||||
|
where
|
||||||
|
F: FnOnce(Value) -> Value,
|
||||||
|
{
|
||||||
|
let (next, _) = self.update_with(|current| (f(current), ())).await?;
|
||||||
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn last_directory(&self) -> Result<Option<PathBuf>> {
|
pub(crate) async fn last_directory(&self) -> Result<Option<PathBuf>> {
|
||||||
|
|||||||
@@ -105,9 +105,30 @@ fn get_config_file() -> PathBuf {
|
|||||||
get_config_dir().join("opencode.json")
|
get_config_dir().join("opencode.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get project config file path
|
/// Get all possible project config paths in priority order
|
||||||
|
/// Priority: root > .opencode/, json > jsonc
|
||||||
|
fn get_project_config_candidates(working_directory: &Path) -> Vec<PathBuf> {
|
||||||
|
vec![
|
||||||
|
working_directory.join("opencode.json"),
|
||||||
|
working_directory.join("opencode.jsonc"),
|
||||||
|
working_directory.join(".opencode").join("opencode.json"),
|
||||||
|
working_directory.join(".opencode").join("opencode.jsonc"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find existing project config file or return default path for new config
|
||||||
fn get_project_config_file(working_directory: &Path) -> PathBuf {
|
fn get_project_config_file(working_directory: &Path) -> PathBuf {
|
||||||
working_directory.join("opencode.json")
|
let candidates = get_project_config_candidates(working_directory);
|
||||||
|
|
||||||
|
// Return first existing config file
|
||||||
|
for candidate in &candidates {
|
||||||
|
if candidate.exists() {
|
||||||
|
return candidate.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to root opencode.json for new configs
|
||||||
|
candidates.into_iter().next().unwrap_or_else(|| working_directory.join("opencode.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get custom config file path from OPENCODE_CONFIG env var
|
/// Get custom config file path from OPENCODE_CONFIG env var
|
||||||
@@ -165,7 +186,9 @@ async fn read_config_file(path: &Path) -> Result<Value> {
|
|||||||
return Ok(Value::Object(serde_json::Map::new()));
|
return Ok(Value::Object(serde_json::Map::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
serde_json::from_str(&normalized).map_err(|e| anyhow!("Failed to parse config: {}", e))
|
serde_json::from_str(&normalized)
|
||||||
|
.or_else(|_| json5::from_str::<serde_json::Value>(&normalized))
|
||||||
|
.map_err(|e| anyhow!("Failed to parse config: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_config_layers(working_directory: Option<&Path>) -> Result<ConfigLayers> {
|
async fn read_config_layers(working_directory: Option<&Path>) -> Result<ConfigLayers> {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ fn normalize_api_prefix(prefix: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OpenCodeManager {
|
impl OpenCodeManager {
|
||||||
pub fn new_with_directory(initial_dir: Option<PathBuf>) -> Self {
|
pub fn new_with_directory(_initial_dir: Option<PathBuf>) -> Self {
|
||||||
let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT")
|
let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|raw| raw.parse::<u16>().ok())
|
.and_then(|raw| raw.parse::<u16>().ok())
|
||||||
@@ -88,7 +88,7 @@ impl OpenCodeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let env = build_augmented_env();
|
let env = build_augmented_env();
|
||||||
let working_dir = initial_dir
|
let working_dir = dirs::home_dir()
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -177,11 +177,13 @@ impl OpenCodeManager {
|
|||||||
self.graceful_stop().await
|
self.graceful_stop().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
|
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
|
||||||
*self.working_dir.write() = new_dir;
|
*self.working_dir.write() = new_dir;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_working_directory(&self) -> PathBuf {
|
pub fn get_working_directory(&self) -> PathBuf {
|
||||||
self.working_dir.read().clone()
|
self.working_dir.read().clone()
|
||||||
}
|
}
|
||||||
@@ -191,7 +193,7 @@ impl OpenCodeManager {
|
|||||||
return Err(anyhow!("Cannot detect API prefix without port"));
|
return Err(anyhow!("Cannot detect API prefix without port"));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try empty prefix first (OpenCode default), then /api (some installations)
|
// Try no prefix first, then /api (compatibility).
|
||||||
let candidates = ["", "/api"];
|
let candidates = ["", "/api"];
|
||||||
for candidate in candidates {
|
for candidate in candidates {
|
||||||
let base = if candidate.is_empty() {
|
let base = if candidate.is_empty() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
@@ -10,6 +10,7 @@ use tauri::{AppHandle, Emitter};
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio_util::io::StreamReader;
|
use tokio_util::io::StreamReader;
|
||||||
|
|
||||||
|
use crate::path_utils::expand_tilde_path;
|
||||||
use crate::DesktopRuntime;
|
use crate::DesktopRuntime;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -126,13 +127,16 @@ async fn run_once(
|
|||||||
// No data received recently; if we are connected to a directory-scoped stream and the working directory
|
// No data received recently; if we are connected to a directory-scoped stream and the working directory
|
||||||
// has changed, reconnect so activity tracking follows the new directory.
|
// has changed, reconnect so activity tracking follows the new directory.
|
||||||
if let SseScope::Directory(connected_dir) = &scope {
|
if let SseScope::Directory(connected_dir) = &scope {
|
||||||
let current_dir = opencode.get_working_directory();
|
if let Some(current_dir) =
|
||||||
if current_dir != *connected_dir {
|
resolve_project_directory_from_settings(runtime).await
|
||||||
debug!(
|
{
|
||||||
"[desktop:activity] Working directory changed; reconnecting activity SSE (from {:?} to {:?})",
|
if current_dir != *connected_dir {
|
||||||
connected_dir, current_dir
|
debug!(
|
||||||
);
|
"[desktop:activity] Project directory changed; reconnecting activity SSE (from {:?} to {:?})",
|
||||||
return Ok(());
|
connected_dir, current_dir
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -183,13 +187,34 @@ fn parse_event_envelope(raw: &str) -> Result<(EventEnvelope, Option<String>)> {
|
|||||||
Ok((multiplexed.payload, multiplexed.directory))
|
Ok((multiplexed.payload, multiplexed.directory))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
|
||||||
|
let settings = runtime.settings().load().await.ok()?;
|
||||||
|
|
||||||
|
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
|
||||||
|
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
|
||||||
|
if let Some(path) = projects.iter().find_map(|entry| {
|
||||||
|
let id = entry.get("id").and_then(Value::as_str)?;
|
||||||
|
if id != active_id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
entry.get("path").and_then(Value::as_str)
|
||||||
|
}) {
|
||||||
|
return Some(expand_tilde_path(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings
|
||||||
|
.get("lastDirectory")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(expand_tilde_path)
|
||||||
|
}
|
||||||
|
|
||||||
async fn connect_activity_sse(
|
async fn connect_activity_sse(
|
||||||
runtime: &DesktopRuntime,
|
runtime: &DesktopRuntime,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base: &str,
|
base: &str,
|
||||||
) -> Result<(reqwest::Response, SseScope)> {
|
) -> Result<(reqwest::Response, SseScope)> {
|
||||||
let opencode = runtime.opencode_manager();
|
|
||||||
|
|
||||||
let global_url = format!("{base}/global/event");
|
let global_url = format!("{base}/global/event");
|
||||||
match try_connect_sse(client, &global_url, "[desktop:activity]").await {
|
match try_connect_sse(client, &global_url, "[desktop:activity]").await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
@@ -216,7 +241,9 @@ async fn connect_activity_sse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let working_dir = opencode.get_working_directory();
|
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
|
||||||
|
anyhow::bail!("No project directory available for SSE fallback");
|
||||||
|
};
|
||||||
let directory = working_dir.to_string_lossy().to_string();
|
let directory = working_dir.to_string_lossy().to_string();
|
||||||
let mut parsed = reqwest::Url::parse(&event_url)?;
|
let mut parsed = reqwest::Url::parse(&event_url)?;
|
||||||
parsed
|
parsed
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"exceptionDomain": "localhost",
|
"exceptionDomain": "localhost",
|
||||||
"minimumSystemVersion": "14.0",
|
"minimumSystemVersion": "14.0",
|
||||||
"signingIdentity": null,
|
"signingIdentity": null,
|
||||||
|
"entitlements": "./entitlements.plist",
|
||||||
"infoPlist": "Info.plist",
|
"infoPlist": "Info.plist",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"appPosition": {
|
"appPosition": {
|
||||||
|
|||||||
@@ -144,22 +144,12 @@ window.opencodeDesktop = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getSettings(): Promise<DesktopSettings> {
|
async getSettings(): Promise<DesktopSettings> {
|
||||||
try {
|
const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings');
|
||||||
const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings');
|
return result.settings;
|
||||||
return result.settings;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error loading settings:', error);
|
|
||||||
return {} as DesktopSettings;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> {
|
async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> {
|
||||||
try {
|
const result = await invoke<DesktopSettings>('save_settings', { changes });
|
||||||
const result = await invoke<DesktopSettings>('save_settings', { changes });
|
return result;
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error updating settings:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async restartOpenCode() {
|
async restartOpenCode() {
|
||||||
try {
|
try {
|
||||||
@@ -188,22 +178,42 @@ window.opencodeDesktop = {
|
|||||||
markRendererReady() {
|
markRendererReady() {
|
||||||
|
|
||||||
},
|
},
|
||||||
async requestDirectoryAccess() {
|
async requestDirectoryAccess(directoryPath?: string) {
|
||||||
try {
|
try {
|
||||||
|
const normalized = typeof directoryPath === 'string' ? directoryPath.trim() : '';
|
||||||
|
|
||||||
|
// When the UI already picked a path (typed / directory tree), skip native dialog.
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
const result = await invoke<{
|
||||||
|
success: boolean;
|
||||||
|
path?: string;
|
||||||
|
projectId?: string;
|
||||||
|
error?: string;
|
||||||
|
}>('process_directory_selection', {
|
||||||
|
path: normalized,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
title: 'Select Working Directory'
|
title: 'Select Working Directory',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!selected || typeof selected !== 'string') {
|
if (!selected || typeof selected !== 'string') {
|
||||||
return { success: false, error: 'Directory selection cancelled' };
|
return { success: false, error: 'Directory selection cancelled' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await invoke<{ success: boolean; path?: string; error?: string }>('process_directory_selection', {
|
const result = await invoke<{
|
||||||
path: selected
|
success: boolean;
|
||||||
|
path?: string;
|
||||||
|
projectId?: string;
|
||||||
|
error?: string;
|
||||||
|
}>('process_directory_selection', {
|
||||||
|
path: selected,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
|
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@opencode-ai/sdk": "^1.0.209",
|
"@opencode-ai/sdk": "^1.1.1",
|
||||||
"@pierre/diffs": "^1.0.0",
|
"@pierre/diffs": "^1.0.0",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|||||||
+16
-3
@@ -44,7 +44,7 @@ type AppProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function App({ apis }: AppProps) {
|
function App({ apis }: AppProps) {
|
||||||
const { initializeApp, isInitialized } = useConfigStore();
|
const { initializeApp, isInitialized, isConnected } = useConfigStore();
|
||||||
const { error, clearError, loadSessions } = useSessionStore();
|
const { error, clearError, loadSessions } = useSessionStore();
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
||||||
@@ -121,11 +121,16 @@ function App({ apis }: AppProps) {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
// VS Code runtime bootstraps config + sessions after the managed OpenCode instance reports "connected".
|
||||||
|
// Doing the default initialization here can race with startup and lead to one-shot failures.
|
||||||
|
if (isVSCodeRuntime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await initializeApp();
|
await initializeApp();
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [initializeApp]);
|
}, [initializeApp, isVSCodeRuntime]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isSwitchingDirectory) {
|
if (isSwitchingDirectory) {
|
||||||
@@ -133,13 +138,21 @@ function App({ apis }: AppProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const syncDirectoryAndSessions = async () => {
|
const syncDirectoryAndSessions = async () => {
|
||||||
|
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
|
||||||
|
if (isVSCodeRuntime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
opencodeClient.setDirectory(currentDirectory);
|
opencodeClient.setDirectory(currentDirectory);
|
||||||
|
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
};
|
};
|
||||||
|
|
||||||
syncDirectoryAndSessions();
|
syncDirectoryAndSessions();
|
||||||
}, [currentDirectory, isSwitchingDirectory, loadSessions]);
|
}, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]);
|
||||||
|
|
||||||
useEventStream();
|
useEventStream();
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { toast } from 'sonner';
|
|||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -47,14 +48,68 @@ interface ChatInputProps {
|
|||||||
|
|
||||||
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
||||||
|
|
||||||
/**
|
type PermissionAction = 'allow' | 'ask' | 'deny';
|
||||||
* Detects if a keyboard event is part of IME composition.
|
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
|
||||||
* Uses both isComposing and keyCode === 229 (MDN recommended).
|
|
||||||
* WebKit may fire compositionend before keydown, causing isComposing to be false
|
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
|
||||||
* while keyCode remains 229, so both checks are needed.
|
if (!Array.isArray(value)) {
|
||||||
*/
|
return null;
|
||||||
const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => {
|
}
|
||||||
return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229;
|
const rules: PermissionRule[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = entry as Partial<PermissionRule>;
|
||||||
|
if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => {
|
||||||
|
const rules = asPermissionRuleset(ruleset);
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
||||||
|
const rule = rules[i];
|
||||||
|
if (rule.permission === permission && rule.pattern === '*') {
|
||||||
|
return rule.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
||||||
|
const rule = rules[i];
|
||||||
|
if (rule.permission === '*' && rule.pattern === '*') {
|
||||||
|
return rule.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPermissionActionMap = (ruleset: unknown, permission: string): Record<string, PermissionAction | undefined> | undefined => {
|
||||||
|
const rules = asPermissionRuleset(ruleset);
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map: Record<string, PermissionAction | undefined> = {};
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.permission !== permission) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map[rule.pattern] = rule.action;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(map).length > 0 ? map : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
||||||
@@ -184,19 +239,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}, [agents, currentAgentName]);
|
}, [agents, currentAgentName]);
|
||||||
|
|
||||||
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
|
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
|
||||||
const agentPermissionRaw = currentAgent?.permission?.edit;
|
if (!currentAgent) {
|
||||||
let defaultMode: EditPermissionMode = 'ask';
|
return 'deny';
|
||||||
|
|
||||||
if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') {
|
|
||||||
defaultMode = agentPermissionRaw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false;
|
const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask';
|
||||||
if (!currentAgent || !editToolConfigured) {
|
return action;
|
||||||
defaultMode = 'deny';
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultMode;
|
|
||||||
}, [currentAgent]);
|
}, [currentAgent]);
|
||||||
|
|
||||||
const sessionAgentEditOverride = useSessionStore(
|
const sessionAgentEditOverride = useSessionStore(
|
||||||
@@ -209,8 +257,20 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}, [currentSessionId, currentAgentName])
|
}, [currentSessionId, currentAgentName])
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentWebfetchPermission = currentAgent?.permission?.webfetch;
|
const agentWebfetchPermission = React.useMemo(() => {
|
||||||
const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined;
|
if (!currentAgent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch');
|
||||||
|
}, [currentAgent]);
|
||||||
|
|
||||||
|
const agentBashPermission = React.useMemo<BashPermissionSetting | undefined>(() => {
|
||||||
|
if (!currentAgent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const map = buildPermissionActionMap(currentAgent.permission, 'bash');
|
||||||
|
return map ? (map as BashPermissionSetting) : undefined;
|
||||||
|
}, [currentAgent]);
|
||||||
|
|
||||||
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
|
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
|
||||||
agentDefaultEditMode,
|
agentDefaultEditMode,
|
||||||
@@ -486,8 +546,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]);
|
}, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
// Early return during IME composition to prevent interference with autocomplete
|
// Early return during IME composition to prevent interference with autocomplete.
|
||||||
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown
|
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
|
||||||
if (isIMECompositionEvent(e)) return;
|
if (isIMECompositionEvent(e)) return;
|
||||||
|
|
||||||
if (showCommandAutocomplete && commandRef.current) {
|
if (showCommandAutocomplete && commandRef.current) {
|
||||||
@@ -521,7 +581,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle Enter/Ctrl+Enter based on queue mode
|
// Handle Enter/Ctrl+Enter based on queue mode
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !isMobile && !isIMECompositionEvent(e)) {
|
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import type { Message, Part } from '@opencode-ai/sdk/v2';
|
|||||||
|
|
||||||
import ChatMessage from './ChatMessage';
|
import ChatMessage from './ChatMessage';
|
||||||
import { PermissionCard } from './PermissionCard';
|
import { PermissionCard } from './PermissionCard';
|
||||||
import type { Permission } from '@/types/permission';
|
import type { PermissionRequest } from '@/types/permission';
|
||||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
||||||
import { useTurnGrouping } from './hooks/useTurnGrouping';
|
import { useTurnGrouping } from './hooks/useTurnGrouping';
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: { info: Message; parts: Part[] }[];
|
messages: { info: Message; parts: Part[] }[];
|
||||||
permissions: Permission[];
|
permissions: PermissionRequest[];
|
||||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
hasMoreAbove: boolean;
|
hasMoreAbove: boolean;
|
||||||
|
|||||||
@@ -55,6 +55,70 @@ type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
|
|||||||
|
|
||||||
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
||||||
|
|
||||||
|
type PermissionAction = 'allow' | 'ask' | 'deny';
|
||||||
|
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
|
||||||
|
|
||||||
|
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rules: PermissionRule[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = entry as Partial<PermissionRule>;
|
||||||
|
if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => {
|
||||||
|
const rules = asPermissionRuleset(ruleset);
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
||||||
|
const rule = rules[i];
|
||||||
|
if (rule.permission === permission && rule.pattern === '*') {
|
||||||
|
return rule.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
||||||
|
const rule = rules[i];
|
||||||
|
if (rule.permission === '*' && rule.pattern === '*') {
|
||||||
|
return rule.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPermissionActionMap = (ruleset: unknown, permission: string): Record<string, PermissionAction | undefined> | undefined => {
|
||||||
|
const rules = asPermissionRuleset(ruleset);
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map: Record<string, PermissionAction | undefined> = {};
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.permission !== permission) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map[rule.pattern] = rule.action;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(map).length > 0 ? map : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
interface CapabilityDefinition {
|
interface CapabilityDefinition {
|
||||||
key: 'tool_call' | 'reasoning';
|
key: 'tool_call' | 'reasoning';
|
||||||
icon: IconComponent;
|
icon: IconComponent;
|
||||||
@@ -314,19 +378,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
}, [desktopModelQuery]);
|
}, [desktopModelQuery]);
|
||||||
|
|
||||||
const currentAgent = getCurrentAgent?.();
|
const currentAgent = getCurrentAgent?.();
|
||||||
const agentPermissionRaw = currentAgent?.permission?.edit;
|
|
||||||
let agentDefaultEditMode: EditPermissionMode = 'ask';
|
|
||||||
if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') {
|
|
||||||
agentDefaultEditMode = agentPermissionRaw;
|
|
||||||
}
|
|
||||||
|
|
||||||
const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false;
|
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
|
||||||
if (!currentAgent || !editToolConfigured) {
|
if (!currentAgent) {
|
||||||
agentDefaultEditMode = 'deny';
|
return 'deny';
|
||||||
}
|
}
|
||||||
|
const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask';
|
||||||
|
return action;
|
||||||
|
}, [currentAgent]);
|
||||||
|
|
||||||
const agentWebfetchPermission = currentAgent?.permission?.webfetch;
|
const agentWebfetchPermission = React.useMemo(() => {
|
||||||
const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined;
|
if (!currentAgent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch');
|
||||||
|
}, [currentAgent]);
|
||||||
|
|
||||||
|
const agentBashPermission = React.useMemo<BashPermissionSetting | undefined>(() => {
|
||||||
|
if (!currentAgent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const map = buildPermissionActionMap(currentAgent.permission, 'bash');
|
||||||
|
return map ? (map as BashPermissionSetting) : undefined;
|
||||||
|
}, [currentAgent]);
|
||||||
|
|
||||||
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
|
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
|
||||||
agentDefaultEditMode,
|
agentDefaultEditMode,
|
||||||
@@ -994,27 +1068,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
const renderMobileAgentTooltip = () => {
|
const renderMobileAgentTooltip = () => {
|
||||||
if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
|
if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
|
||||||
|
|
||||||
const enabledTools = Object.entries(currentAgent.tools || {})
|
|
||||||
.filter(([, enabled]) => enabled)
|
|
||||||
.map(([tool]) => tool)
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
|
const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
|
||||||
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
|
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
|
||||||
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
|
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
|
||||||
|
|
||||||
const getPermissionIcon = (permission?: string) => {
|
const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
|
||||||
const mode: EditPermissionMode =
|
const rules = asPermissionRuleset(currentAgent.permission) ?? [];
|
||||||
permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask';
|
const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
|
||||||
return renderEditModeIcon(mode, 'h-3.5 w-3.5');
|
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
|
||||||
|
|
||||||
|
if (hasCustom) {
|
||||||
|
return { mode: 'ask', label: 'Custom' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
|
||||||
|
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
|
||||||
|
return { mode: 'ask', label: 'Ask' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPermissionLabel = (permission?: string) => {
|
const editPermissionSummary = summarizePermission('edit');
|
||||||
if (permission === 'full') return 'Full';
|
const bashPermissionSummary = summarizePermission('bash');
|
||||||
if (permission === 'allow') return 'Allow';
|
const webfetchPermissionSummary = summarizePermission('webfetch');
|
||||||
if (permission === 'deny') return 'Deny';
|
|
||||||
return 'Ask';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileOverlayPanel
|
<MobileOverlayPanel
|
||||||
@@ -1066,24 +1140,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{}
|
|
||||||
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
|
|
||||||
<div className="typography-micro text-muted-foreground mb-1">Tools</div>
|
|
||||||
{enabledTools.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1 leading-tight">
|
|
||||||
{enabledTools.map((tool) => (
|
|
||||||
<span
|
|
||||||
key={tool}
|
|
||||||
className="inline-flex items-center rounded-lg bg-muted/60 px-1.5 py-0.5 typography-meta text-foreground"
|
|
||||||
>
|
|
||||||
{tool}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="typography-meta text-muted-foreground">All enabled</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{}
|
{}
|
||||||
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
|
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
|
||||||
@@ -1092,27 +1148,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="typography-meta text-muted-foreground/80">Edit</span>
|
<span className="typography-meta text-muted-foreground/80">Edit</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getPermissionIcon(currentAgent.permission?.edit)}
|
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||||
<span className="typography-meta font-medium text-foreground">
|
<span className="typography-meta font-medium text-foreground">
|
||||||
{getPermissionLabel(currentAgent.permission?.edit)}
|
{editPermissionSummary.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="typography-meta text-muted-foreground/80">Bash</span>
|
<span className="typography-meta text-muted-foreground/80">Bash</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
|
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||||
<span className="typography-meta font-medium text-foreground">
|
<span className="typography-meta font-medium text-foreground">
|
||||||
{getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
|
{bashPermissionSummary.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="typography-meta text-muted-foreground/80">WebFetch</span>
|
<span className="typography-meta text-muted-foreground/80">WebFetch</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getPermissionIcon(currentAgent.permission?.webfetch)}
|
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||||
<span className="typography-meta font-medium text-foreground">
|
<span className="typography-meta font-medium text-foreground">
|
||||||
{getPermissionLabel(currentAgent.permission?.webfetch)}
|
{webfetchPermissionSummary.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1983,27 +2039,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledTools = Object.entries(currentAgent.tools || {})
|
|
||||||
.filter(([, enabled]) => enabled)
|
|
||||||
.map(([tool]) => tool)
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
|
const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
|
||||||
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
|
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
|
||||||
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
|
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
|
||||||
|
|
||||||
const getPermissionIcon = (permission?: string) => {
|
const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
|
||||||
const mode: EditPermissionMode =
|
const rules = asPermissionRuleset(currentAgent.permission) ?? [];
|
||||||
permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask';
|
const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
|
||||||
return renderEditModeIcon(mode, 'h-3.5 w-3.5');
|
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
|
||||||
|
|
||||||
|
if (hasCustom) {
|
||||||
|
return { mode: 'ask', label: 'Custom' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
|
||||||
|
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
|
||||||
|
return { mode: 'ask', label: 'Ask' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPermissionLabel = (permission?: string) => {
|
const editPermissionSummary = summarizePermission('edit');
|
||||||
if (permission === 'full') return 'Full';
|
const bashPermissionSummary = summarizePermission('bash');
|
||||||
if (permission === 'allow') return 'Allow';
|
const webfetchPermissionSummary = summarizePermission('webfetch');
|
||||||
if (permission === 'deny') return 'Deny';
|
|
||||||
return 'Ask';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipContent align="start" sideOffset={8} className="max-w-[280px]">
|
<TooltipContent align="start" sideOffset={8} className="max-w-[280px]">
|
||||||
@@ -2053,50 +2109,33 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Tools</span>
|
|
||||||
{enabledTools.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1 leading-tight">
|
|
||||||
{enabledTools.map((tool) => (
|
|
||||||
<span
|
|
||||||
key={tool}
|
|
||||||
className="inline-flex items-center rounded-lg bg-muted/60 px-1.5 py-0.5 typography-meta text-foreground"
|
|
||||||
>
|
|
||||||
{tool}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="typography-meta text-muted-foreground">All enabled</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Permissions</span>
|
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Permissions</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="typography-meta text-muted-foreground/80 w-16">Edit</span>
|
<span className="typography-meta text-muted-foreground/80 w-16">Edit</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getPermissionIcon(currentAgent.permission?.edit)}
|
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||||
<span className="typography-meta font-medium text-foreground w-12">
|
<span className="typography-meta font-medium text-foreground w-12">
|
||||||
{getPermissionLabel(currentAgent.permission?.edit)}
|
{editPermissionSummary.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="typography-meta text-muted-foreground/80 w-16">Bash</span>
|
<span className="typography-meta text-muted-foreground/80 w-16">Bash</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
|
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||||
<span className="typography-meta font-medium text-foreground w-12">
|
<span className="typography-meta font-medium text-foreground w-12">
|
||||||
{getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
|
{bashPermissionSummary.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="typography-meta text-muted-foreground/80 w-16">WebFetch</span>
|
<span className="typography-meta text-muted-foreground/80 w-16">WebFetch</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getPermissionIcon(currentAgent.permission?.webfetch)}
|
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
|
||||||
<span className="typography-meta font-medium text-foreground w-12">
|
<span className="typography-meta font-medium text-foreground w-12">
|
||||||
{getPermissionLabel(currentAgent.permission?.webfetch)}
|
{webfetchPermissionSummary.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Permission, PermissionResponse } from '@/types/permission';
|
import type { PermissionRequest, PermissionResponse } from '@/types/permission';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
@@ -10,7 +10,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|||||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||||
|
|
||||||
interface PermissionCardProps {
|
interface PermissionCardProps {
|
||||||
permission: Permission;
|
permission: PermissionRequest;
|
||||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolName = permission.type || 'Unknown Tool';
|
const toolName = permission.permission || 'unknown';
|
||||||
const tool = toolName.toLowerCase();
|
const tool = toolName.toLowerCase();
|
||||||
|
|
||||||
const getMeta = (key: string, fallback: string = ''): string => {
|
const getMeta = (key: string, fallback: string = ''): string => {
|
||||||
@@ -106,9 +106,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
const description = getMeta('description');
|
const description = getMeta('description');
|
||||||
const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path');
|
const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path');
|
||||||
const timeout = getMetaNum('timeout');
|
const timeout = getMetaNum('timeout');
|
||||||
|
|
||||||
const commandInTitle = permission.title === command;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{description && (
|
{description && (
|
||||||
@@ -125,7 +123,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{}
|
{}
|
||||||
{command && !commandInTitle && (
|
{command && (
|
||||||
<div>
|
<div>
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language="bash"
|
language="bash"
|
||||||
@@ -329,110 +327,15 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
|
|
||||||
{}
|
{}
|
||||||
<div className="px-2 py-2">
|
<div className="px-2 py-2">
|
||||||
{/* Show patterns being requested */}
|
{permission.patterns.length > 0 && (
|
||||||
{(permission.patterns as string[]) && (permission.patterns as string[]).length > 0 && (
|
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="typography-meta text-muted-foreground mb-1">Patterns:</div>
|
<div className="typography-meta text-muted-foreground mb-1">Patterns:</div>
|
||||||
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
||||||
{(permission.patterns as string[]).join(", ")}
|
{permission.patterns.join(", ")}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!((permission.patterns as string[]) && (permission.patterns as string[]).length > 0) &&
|
|
||||||
(permission.pattern as string | string[]) &&
|
|
||||||
<div className="mb-2">
|
|
||||||
<div className="typography-meta text-muted-foreground mb-1">Pattern:</div>
|
|
||||||
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
|
||||||
{Array.isArray(permission.pattern) ? permission.pattern.join(", ") : permission.pattern}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
|
|
||||||
let primaryContent = '';
|
|
||||||
let primaryLanguage = 'text';
|
|
||||||
let shouldHighlight = false;
|
|
||||||
|
|
||||||
if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
|
|
||||||
primaryContent = getMeta('command') || getMeta('cmd') || getMeta('script');
|
|
||||||
primaryLanguage = 'bash';
|
|
||||||
shouldHighlight = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool' || tool === 'write' || tool === 'create' || tool === 'file_write') {
|
|
||||||
primaryContent = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
|
||||||
shouldHighlight = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (tool === 'webfetch' || tool === 'fetch') {
|
|
||||||
primaryContent = getMeta('url') || getMeta('uri') || getMeta('endpoint');
|
|
||||||
shouldHighlight = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleMatchesContent = permission.title === primaryContent;
|
|
||||||
|
|
||||||
if (titleMatchesContent && primaryContent && shouldHighlight) {
|
|
||||||
return (
|
|
||||||
<div className="mb-3">
|
|
||||||
<SyntaxHighlighter
|
|
||||||
language={primaryLanguage}
|
|
||||||
style={syntaxTheme}
|
|
||||||
PreTag="div"
|
|
||||||
customStyle={{
|
|
||||||
margin: 0,
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: 'var(--text-meta)',
|
|
||||||
lineHeight: '1.25rem',
|
|
||||||
background: 'rgb(var(--muted) / 0.3)',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
overflowWrap: 'break-word',
|
|
||||||
overflow: 'visible'
|
|
||||||
}}
|
|
||||||
codeTagProps={{
|
|
||||||
style: {
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
overflowWrap: 'break-word'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
wrapLongLines={true}
|
|
||||||
>
|
|
||||||
{primaryContent}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (titleMatchesContent && primaryContent && !shouldHighlight) {
|
|
||||||
return (
|
|
||||||
<div className="mb-3">
|
|
||||||
<code className="typography-ui-label px-2 py-1 bg-muted/30 rounded block break-all">
|
|
||||||
{primaryContent}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (permission.title) {
|
|
||||||
return (
|
|
||||||
<div className={cn(
|
|
||||||
"typography-ui-label text-foreground mb-3",
|
|
||||||
|
|
||||||
(shouldHighlight || primaryContent) && "font-mono"
|
|
||||||
)}>
|
|
||||||
{permission.title}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{}
|
|
||||||
{renderToolContent()}
|
{renderToolContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -460,7 +363,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
Allow Once
|
Allow Once
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{(permission.always as string[]) && (permission.always as string[]).length > 0 ? (
|
{permission.always.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleResponse('always')}
|
onClick={() => handleResponse('always')}
|
||||||
disabled={isResponding}
|
disabled={isResponding}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
|
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Permission, PermissionResponse } from '@/types/permission';
|
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
|
||||||
interface PermissionRequestProps {
|
interface PermissionRequestProps {
|
||||||
permission: Permission;
|
permission: PermissionRequestPayload;
|
||||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
|
|
||||||
const command = typeof permission.metadata.command === 'string'
|
const command = typeof permission.metadata.command === 'string'
|
||||||
? permission.metadata.command
|
? permission.metadata.command
|
||||||
: permission.title;
|
: (permission.patterns?.[0] ?? permission.permission);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stripTaskMetadataFromOutput = (output: string): string => {
|
const stripTaskMetadataFromOutput = (output: string): string => {
|
||||||
// OpenCode appends a non-user-facing session marker for task tools.
|
|
||||||
// Strip only a trailing <task_metadata>...</task_metadata> block.
|
// Strip only a trailing <task_metadata>...</task_metadata> block.
|
||||||
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
const messages = useSessionStore((state) => state.messages);
|
const messages = useSessionStore((state) => state.messages);
|
||||||
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
|
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
|
||||||
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
|
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
|
||||||
|
const lastBootstrapAttemptAt = React.useRef<number>(0);
|
||||||
|
|
||||||
// Navigate to chat when a session is selected
|
// Navigate to chat when a session is selected
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -54,6 +55,16 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
|
|
||||||
// Listen for connection status changes
|
// Listen for connection status changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// Catch up with the latest status even if the extension posted the connection message
|
||||||
|
// before this component registered the event listener.
|
||||||
|
const current =
|
||||||
|
(typeof window !== 'undefined'
|
||||||
|
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status
|
||||||
|
: undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined;
|
||||||
|
if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') {
|
||||||
|
setConnectionStatus(current);
|
||||||
|
}
|
||||||
|
|
||||||
const handler = (event: Event) => {
|
const handler = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
|
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
|
||||||
const status = detail?.status;
|
const status = detail?.status;
|
||||||
@@ -88,12 +99,43 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') {
|
if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastBootstrapAttemptAt.current < 750) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastBootstrapAttemptAt.current = now;
|
||||||
setIsInitializing(true);
|
setIsInitializing(true);
|
||||||
try {
|
try {
|
||||||
|
const debugEnabled = (() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem('openchamber_stream_debug') === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] attempt', { configInitialized });
|
||||||
if (!configInitialized) {
|
if (!configInitialized) {
|
||||||
await initializeConfig();
|
await initializeConfig();
|
||||||
}
|
}
|
||||||
|
const configState = useConfigStore.getState();
|
||||||
|
// If OpenCode is still warming up, the initial provider/agent loads can fail and be swallowed by retries.
|
||||||
|
// Only mark bootstrap complete when core datasets are present so we keep retrying on cold starts.
|
||||||
|
if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
|
const sessionsError = useSessionStore.getState().error;
|
||||||
|
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', {
|
||||||
|
providers: configState.providers.length,
|
||||||
|
agents: configState.agents.length,
|
||||||
|
sessions: useSessionStore.getState().sessions.length,
|
||||||
|
sessionsError,
|
||||||
|
});
|
||||||
|
if (typeof sessionsError === 'string' && sessionsError.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setHasInitializedOnce(true);
|
setHasInitializedOnce(true);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore bootstrap failures
|
// Ignore bootstrap failures
|
||||||
@@ -262,5 +304,3 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
SelectContent,
|
SelectContent,
|
||||||
SelectGroup,
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
@@ -36,25 +35,51 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||||
|
const defaultAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
const agents = getVisibleAgents();
|
const agents = getVisibleAgents();
|
||||||
|
const selectableAgents = React.useMemo(
|
||||||
|
() => agents.filter((agent) => agent.mode !== 'subagent'),
|
||||||
|
[agents]
|
||||||
|
);
|
||||||
|
|
||||||
// Load agents on mount
|
// Load agents on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadAgents();
|
loadAgents();
|
||||||
}, [loadAgents]);
|
}, [loadAgents]);
|
||||||
|
|
||||||
// Use empty string to represent "no agent" selection
|
// Ensure we always have a valid selection (defaults to current default agent, then first selectable agent).
|
||||||
const handleValueChange = (newValue: string) => {
|
React.useEffect(() => {
|
||||||
onChange(newValue === '__none__' ? '' : newValue);
|
if (disabled) {
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert empty value to __none__ for the Select component (which doesn't handle empty strings well)
|
const trimmedValue = value.trim();
|
||||||
const selectValue = value || '__none__';
|
if (trimmedValue.length > 0 && selectableAgents.some((agent) => agent.name === trimmedValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateDefault =
|
||||||
|
typeof defaultAgentName === 'string' && defaultAgentName.trim().length > 0
|
||||||
|
? defaultAgentName.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (candidateDefault && selectableAgents.some((agent) => agent.name === candidateDefault)) {
|
||||||
|
onChange(candidateDefault);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstAgent = selectableAgents[0]?.name;
|
||||||
|
if (firstAgent) {
|
||||||
|
onChange(firstAgent);
|
||||||
|
}
|
||||||
|
}, [defaultAgentName, disabled, onChange, selectableAgents, value]);
|
||||||
|
|
||||||
|
const selectValue = value.trim().length > 0 ? value : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={selectValue}
|
value={selectValue}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
@@ -62,20 +87,12 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
|||||||
size="lg"
|
size="lg"
|
||||||
className={className ?? 'max-w-full typography-meta text-foreground'}
|
className={className ?? 'max-w-full typography-meta text-foreground'}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Select an agent (optional)" />
|
<SelectValue placeholder="Select an agent" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent fitContent>
|
<SelectContent fitContent>
|
||||||
<SelectGroup>
|
{selectableAgents.length > 0 && (
|
||||||
<SelectLabel>Default</SelectLabel>
|
|
||||||
<SelectItem value="__none__" className="w-auto whitespace-nowrap">
|
|
||||||
No agent (default)
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
|
|
||||||
{agents.length > 0 && (
|
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>Agents</SelectLabel>
|
{selectableAgents.map((agent) => (
|
||||||
{agents.map((agent) => (
|
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={agent.name}
|
key={agent.name}
|
||||||
value={agent.name}
|
value={agent.name}
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
id,
|
id,
|
||||||
}) => {
|
}) => {
|
||||||
const { branches, isLoading, isGitRepository } = useBranchOptions(directory);
|
const { branches, isLoading, isGitRepository } = useBranchOptions(directory);
|
||||||
|
const selectedLabel = React.useMemo(() => {
|
||||||
|
return branches.find((option) => option.value === value)?.label ?? null;
|
||||||
|
}, [branches, value]);
|
||||||
|
|
||||||
// Update value if it's no longer valid
|
// Update value if it's no longer valid
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -149,9 +152,11 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
size="lg"
|
size="lg"
|
||||||
className={className ?? 'max-w-full typography-meta text-foreground'}
|
className={className ?? 'max-w-full typography-meta text-foreground'}
|
||||||
>
|
>
|
||||||
<SelectValue
|
{selectedLabel ? (
|
||||||
placeholder={isLoading ? 'Loading branches…' : 'Select a branch'}
|
<SelectValue>{selectedLabel}</SelectValue>
|
||||||
/>
|
) : (
|
||||||
|
<SelectValue placeholder={isLoading ? 'Loading branches…' : 'Select a branch'} />
|
||||||
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent fitContent>
|
<SelectContent fitContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useModelLists } from '@/hooks/useModelLists';
|
import { useModelLists } from '@/hooks/useModelLists';
|
||||||
import type { ModelMetadata } from '@/types';
|
import type { ModelMetadata } from '@/types';
|
||||||
@@ -325,6 +326,9 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
|||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (isIMECompositionEvent(e)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
id="multirun-agent"
|
id="multirun-agent"
|
||||||
/>
|
/>
|
||||||
<p className="typography-micro text-muted-foreground">
|
<p className="typography-micro text-muted-foreground">
|
||||||
Optional agent to use for all runs.
|
Defaults to your configured default agent.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
|
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
|
||||||
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope } from '@/stores/useAgentsStore';
|
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
@@ -30,6 +30,103 @@ interface AgentsSidebarProps {
|
|||||||
onItemSelect?: () => void;
|
onItemSelect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PermissionAction = 'allow' | 'ask' | 'deny';
|
||||||
|
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
|
||||||
|
|
||||||
|
type PermissionConfigValue = PermissionAction | Record<string, PermissionAction>;
|
||||||
|
|
||||||
|
// OpenCode's built-in defaults for permissions that differ from "allow"
|
||||||
|
const getOpenCodeDefaultActionForPermission = (permissionName: string): PermissionAction => {
|
||||||
|
if (permissionName === 'doom_loop' || permissionName === 'external_directory') {
|
||||||
|
return 'ask';
|
||||||
|
}
|
||||||
|
return 'allow';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPermissionRuleset = (ruleset: unknown): PermissionRule[] => {
|
||||||
|
if (!Array.isArray(ruleset)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: PermissionRule[] = [];
|
||||||
|
for (const entry of ruleset) {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = entry as Partial<PermissionRule>;
|
||||||
|
if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parsed.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] => {
|
||||||
|
const parsed = toPermissionRuleset(ruleset);
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byPermission: Record<string, Record<string, PermissionAction>> = {};
|
||||||
|
for (const rule of parsed) {
|
||||||
|
if (!rule.permission) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
(byPermission[rule.permission] ||= {})[rule.pattern] = rule.action;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the global default (wildcard * with pattern *)
|
||||||
|
const globalDefault = byPermission['*']?.['*'];
|
||||||
|
|
||||||
|
const permissionNames = Object.keys(byPermission);
|
||||||
|
if (
|
||||||
|
permissionNames.length === 1 &&
|
||||||
|
permissionNames[0] === '*' &&
|
||||||
|
Object.keys(byPermission['*'] || {}).length === 1 &&
|
||||||
|
byPermission['*']?.['*']
|
||||||
|
) {
|
||||||
|
return byPermission['*']['*'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, PermissionConfigValue> = {};
|
||||||
|
for (const permissionName of permissionNames) {
|
||||||
|
const map = byPermission[permissionName];
|
||||||
|
const patterns = Object.keys(map);
|
||||||
|
|
||||||
|
// For wildcard-only entries, check if they're redundant
|
||||||
|
if (patterns.length === 1 && patterns[0] === '*' && permissionName !== '*') {
|
||||||
|
const action = map['*'];
|
||||||
|
const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName);
|
||||||
|
|
||||||
|
// Skip if this permission is redundant (matches effective default)
|
||||||
|
if (globalDefault) {
|
||||||
|
if (action === globalDefault) continue;
|
||||||
|
} else {
|
||||||
|
if (action === opencodeDefault) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[permissionName] = action;
|
||||||
|
} else if (permissionName === '*') {
|
||||||
|
// Include global default
|
||||||
|
if (patterns.length === 1 && patterns[0] === '*') {
|
||||||
|
result[permissionName] = map['*'];
|
||||||
|
} else {
|
||||||
|
result[permissionName] = map;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-wildcard patterns - include as-is
|
||||||
|
result[permissionName] = map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? (result as AgentDraft['permission']) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) => {
|
export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) => {
|
||||||
const [renameDialogAgent, setRenameDialogAgent] = React.useState<Agent | null>(null);
|
const [renameDialogAgent, setRenameDialogAgent] = React.useState<Agent | null>(null);
|
||||||
const [renameNewName, setRenameNewName] = React.useState('');
|
const [renameNewName, setRenameNewName] = React.useState('');
|
||||||
@@ -132,12 +229,9 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
|
|
||||||
// Set draft with prefilled values from source agent
|
// Set draft with prefilled values from source agent
|
||||||
const extAgent = agent as Agent & { scope?: AgentScope };
|
const extAgent = agent as Agent & { scope?: AgentScope };
|
||||||
// Convert model object to string if needed (SDK type vs API type difference)
|
const modelStr = agent.model?.providerID && agent.model?.modelID
|
||||||
const modelStr = typeof agent.model === 'string'
|
? `${agent.model.providerID}/${agent.model.modelID}`
|
||||||
? agent.model
|
: null;
|
||||||
: agent.model?.providerID && agent.model?.modelID
|
|
||||||
? `${agent.model.providerID}/${agent.model.modelID}`
|
|
||||||
: undefined;
|
|
||||||
const draftAgent = agent as Agent & { disable?: boolean };
|
const draftAgent = agent as Agent & { disable?: boolean };
|
||||||
setAgentDraft({
|
setAgentDraft({
|
||||||
name: newName,
|
name: newName,
|
||||||
@@ -148,8 +242,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
top_p: agent.topP,
|
top_p: agent.topP,
|
||||||
prompt: agent.prompt,
|
prompt: agent.prompt,
|
||||||
mode: agent.mode,
|
mode: agent.mode,
|
||||||
tools: agent.tools,
|
permission: rulesetToPermissionConfig(agent.permission),
|
||||||
permission: agent.permission,
|
|
||||||
disable: draftAgent.disable,
|
disable: draftAgent.disable,
|
||||||
});
|
});
|
||||||
setSelectedAgent(newName);
|
setSelectedAgent(newName);
|
||||||
@@ -185,12 +278,9 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new agent with new name and all existing config
|
// Create new agent with new name and all existing config
|
||||||
// Convert model object to string if needed (SDK type vs API type difference)
|
const renameModelStr = renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID
|
||||||
const renameModelStr = typeof renameDialogAgent.model === 'string'
|
? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}`
|
||||||
? renameDialogAgent.model
|
: null;
|
||||||
: renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID
|
|
||||||
? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}`
|
|
||||||
: undefined;
|
|
||||||
const renameExt = renameDialogAgent as Agent & { scope?: AgentScope; disable?: boolean };
|
const renameExt = renameDialogAgent as Agent & { scope?: AgentScope; disable?: boolean };
|
||||||
const success = await createAgent({
|
const success = await createAgent({
|
||||||
name: sanitizedName,
|
name: sanitizedName,
|
||||||
@@ -200,8 +290,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
top_p: renameDialogAgent.topP,
|
top_p: renameDialogAgent.topP,
|
||||||
prompt: renameDialogAgent.prompt,
|
prompt: renameDialogAgent.prompt,
|
||||||
mode: renameDialogAgent.mode,
|
mode: renameDialogAgent.mode,
|
||||||
tools: renameDialogAgent.tools,
|
permission: rulesetToPermissionConfig(renameDialogAgent.permission),
|
||||||
permission: renameDialogAgent.permission,
|
|
||||||
disable: renameExt.disable,
|
disable: renameExt.disable,
|
||||||
scope: renameExt.scope,
|
scope: renameExt.scope,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
const providers = useConfigStore((state) => state.providers);
|
const providers = useConfigStore((state) => state.providers);
|
||||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
|
||||||
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
|
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
|
||||||
|
|
||||||
const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState<Record<string, AuthMethod[]>>({});
|
const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState<Record<string, AuthMethod[]>>({});
|
||||||
@@ -275,8 +274,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
|
|
||||||
toast.success('API key saved');
|
toast.success('API key saved');
|
||||||
setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' }));
|
setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' }));
|
||||||
await reloadOpenCodeConfiguration();
|
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||||
await loadProviders();
|
|
||||||
setSelectedProvider(providerId);
|
setSelectedProvider(providerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save API key:', error);
|
console.error('Failed to save API key:', error);
|
||||||
@@ -375,8 +373,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
toast.success('OAuth connection completed');
|
toast.success('OAuth connection completed');
|
||||||
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
|
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
|
||||||
setPendingOAuth(null);
|
setPendingOAuth(null);
|
||||||
await reloadOpenCodeConfiguration();
|
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||||
await loadProviders();
|
|
||||||
setSelectedProvider(providerId);
|
setSelectedProvider(providerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to complete OAuth flow:', error);
|
console.error('Failed to complete OAuth flow:', error);
|
||||||
@@ -423,8 +420,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Provider disconnected');
|
toast.success('Provider disconnected');
|
||||||
await reloadOpenCodeConfiguration();
|
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||||
await loadProviders();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to disconnect provider:', error);
|
console.error('Failed to disconnect provider:', error);
|
||||||
toast.error('Failed to disconnect provider');
|
toast.error('Failed to disconnect provider');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { DirectoryTree } from './DirectoryTree';
|
import { DirectoryTree } from './DirectoryTree';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -33,7 +34,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentDirectory, homeDirectory, setDirectory, isHomeReady } = useDirectoryStore();
|
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
|
||||||
|
const { addProject, getActiveProject } = useProjectsStore();
|
||||||
const [pendingPath, setPendingPath] = React.useState<string | null>(null);
|
const [pendingPath, setPendingPath] = React.useState<string | null>(null);
|
||||||
const [pathInputValue, setPathInputValue] = React.useState('');
|
const [pathInputValue, setPathInputValue] = React.useState('');
|
||||||
const [hasUserSelection, setHasUserSelection] = React.useState(false);
|
const [hasUserSelection, setHasUserSelection] = React.useState(false);
|
||||||
@@ -67,12 +69,13 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
if (open) {
|
if (open) {
|
||||||
setHasUserSelection(false);
|
setHasUserSelection(false);
|
||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
// Initialize with current directory
|
// Initialize with active project or current directory
|
||||||
const initialPath = currentDirectory || homeDirectory || '';
|
const activeProject = getActiveProject();
|
||||||
|
const initialPath = activeProject?.path || currentDirectory || homeDirectory || '';
|
||||||
setPendingPath(initialPath);
|
setPendingPath(initialPath);
|
||||||
setPathInputValue(formatPath(initialPath));
|
setPathInputValue(formatPath(initialPath));
|
||||||
}
|
}
|
||||||
}, [open, currentDirectory, homeDirectory, formatPath]);
|
}, [open, currentDirectory, homeDirectory, formatPath, getActiveProject]);
|
||||||
|
|
||||||
// Set initial pending path to home when ready (only if not yet selected)
|
// Set initial pending path to home when ready (only if not yet selected)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -104,13 +107,10 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
if (!targetPath || isConfirming) {
|
if (!targetPath || isConfirming) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (targetPath === currentDirectory) {
|
|
||||||
handleClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsConfirming(true);
|
setIsConfirming(true);
|
||||||
try {
|
try {
|
||||||
let resolvedPath = targetPath;
|
let resolvedPath = targetPath;
|
||||||
|
let projectId: string | undefined;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
const accessResult = await requestAccess(targetPath);
|
const accessResult = await requestAccess(targetPath);
|
||||||
@@ -121,6 +121,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolvedPath = accessResult.path ?? targetPath;
|
resolvedPath = accessResult.path ?? targetPath;
|
||||||
|
projectId = accessResult.projectId;
|
||||||
|
|
||||||
const startResult = await startAccessing(resolvedPath);
|
const startResult = await startAccessing(resolvedPath);
|
||||||
if (!startResult.success) {
|
if (!startResult.success) {
|
||||||
@@ -131,7 +132,14 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setDirectory(resolvedPath);
|
const added = addProject(resolvedPath, { id: projectId });
|
||||||
|
if (!added) {
|
||||||
|
toast.error('Failed to add project', {
|
||||||
|
description: 'Please select a valid directory path.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to select directory', {
|
toast.error('Failed to select directory', {
|
||||||
@@ -141,11 +149,10 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
currentDirectory,
|
addProject,
|
||||||
handleClose,
|
handleClose,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
requestAccess,
|
requestAccess,
|
||||||
setDirectory,
|
|
||||||
startAccessing,
|
startAccessing,
|
||||||
isConfirming,
|
isConfirming,
|
||||||
]);
|
]);
|
||||||
@@ -200,9 +207,9 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
|
|
||||||
const dialogHeader = (
|
const dialogHeader = (
|
||||||
<DialogHeader className="flex-shrink-0 px-4 pb-2 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-3 sm:pt-0">
|
<DialogHeader className="flex-shrink-0 px-4 pb-2 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-3 sm:pt-0">
|
||||||
<DialogTitle>Select project directory</DialogTitle>
|
<DialogTitle>Add project directory</DialogTitle>
|
||||||
<DialogDescription className="hidden sm:block">
|
<DialogDescription className="hidden sm:block">
|
||||||
Choose the working directory for sessions and OpenCode operations.
|
Choose a folder to add as a project.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
);
|
);
|
||||||
@@ -304,7 +311,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
disabled={isConfirming || !hasUserSelection || (!pendingPath && !pathInputValue.trim())}
|
disabled={isConfirming || !hasUserSelection || (!pendingPath && !pathInputValue.trim())}
|
||||||
className="flex-1 sm:flex-none sm:w-auto sm:min-w-[140px]"
|
className="flex-1 sm:flex-none sm:w-auto sm:min-w-[140px]"
|
||||||
>
|
>
|
||||||
{isConfirming ? 'Applying...' : 'Open Directory'}
|
{isConfirming ? 'Adding...' : 'Add Project'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -314,7 +321,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
<MobileOverlayPanel
|
<MobileOverlayPanel
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={() => onOpenChange(false)}
|
||||||
title="Select project directory"
|
title="Add project directory"
|
||||||
className="max-w-full"
|
className="max-w-full"
|
||||||
contentMaxHeightClassName="max-h-[min(70vh,520px)] h-[min(70vh,520px)]"
|
contentMaxHeightClassName="max-h-[min(70vh,520px)] h-[min(70vh,520px)]"
|
||||||
footer={<div className="flex flex-row gap-2">{renderActionButtons()}</div>}
|
footer={<div className="flex flex-row gap-2">{renderActionButtons()}</div>}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ import {
|
|||||||
import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi';
|
import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||||
|
import { isDesktopRuntime } from '@/lib/desktop';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
@@ -122,6 +125,7 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false);
|
const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false);
|
||||||
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
|
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
|
||||||
const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false);
|
const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false);
|
||||||
|
const [worktreeManagerProjectId, setWorktreeManagerProjectId] = React.useState<string | null>(null);
|
||||||
const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set());
|
const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set());
|
||||||
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
|
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
|
||||||
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||||
@@ -140,13 +144,22 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
getWorktreeMetadata,
|
getWorktreeMetadata,
|
||||||
isLoading,
|
isLoading,
|
||||||
} = useSessionStore();
|
} = useSessionStore();
|
||||||
const { currentDirectory, homeDirectory, hasPersistedDirectory, isHomeReady } = useDirectoryStore();
|
const { currentDirectory, homeDirectory, isHomeReady, setDirectory } = useDirectoryStore();
|
||||||
|
const { projects, addProject, activeProjectId } = useProjectsStore();
|
||||||
|
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
const { agents } = useConfigStore();
|
const { agents } = useConfigStore();
|
||||||
const { isSessionCreateDialogOpen, setSessionCreateDialogOpen } = useUIStore();
|
const { isSessionCreateDialogOpen, setSessionCreateDialogOpen } = useUIStore();
|
||||||
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
|
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
|
||||||
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
|
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
|
||||||
|
|
||||||
const projectDirectory = React.useMemo(() => normalizeProjectDirectory(currentDirectory), [currentDirectory]);
|
const projectDirectory = React.useMemo(() => {
|
||||||
|
const targetProjectId = worktreeManagerProjectId ?? activeProjectId;
|
||||||
|
const targetProject = targetProjectId
|
||||||
|
? projects.find((project) => project.id === targetProjectId) ?? null
|
||||||
|
: null;
|
||||||
|
const targetPath = targetProject?.path ?? currentDirectory;
|
||||||
|
return normalizeProjectDirectory(targetPath);
|
||||||
|
}, [activeProjectId, currentDirectory, projects, worktreeManagerProjectId]);
|
||||||
const sanitizedNewBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]);
|
const sanitizedNewBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]);
|
||||||
const worktreeTargetBranch = React.useMemo(
|
const worktreeTargetBranch = React.useMemo(
|
||||||
() => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName),
|
() => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName),
|
||||||
@@ -213,15 +226,75 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
loadSessions();
|
loadSessions();
|
||||||
}, [loadSessions, currentDirectory]);
|
}, [loadSessions, currentDirectory]);
|
||||||
|
|
||||||
|
const projectsKey = React.useMemo(
|
||||||
|
() => projects.map((project) => `${project.id}:${project.path}`).join('|'),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
const lastProjectsKeyRef = React.useRef(projectsKey);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!hasShownInitialDirectoryPrompt && isHomeReady && !hasPersistedDirectory) {
|
if (projectsKey === lastProjectsKeyRef.current) {
|
||||||
setIsDirectoryDialogOpen(true);
|
return;
|
||||||
setHasShownInitialDirectoryPrompt(true);
|
|
||||||
}
|
}
|
||||||
}, [hasPersistedDirectory, hasShownInitialDirectoryPrompt, isHomeReady]);
|
|
||||||
|
lastProjectsKeyRef.current = projectsKey;
|
||||||
|
loadSessions();
|
||||||
|
}, [loadSessions, projectsKey]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasShownInitialDirectoryPrompt(true);
|
||||||
|
|
||||||
|
if (isDesktopRuntime()) {
|
||||||
|
requestAccess('')
|
||||||
|
.then(async (result) => {
|
||||||
|
if (!result.success || !result.path) {
|
||||||
|
if (result.error && result.error !== 'Directory selection cancelled') {
|
||||||
|
toast.error('Failed to select directory', {
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessResult = await startAccessing(result.path);
|
||||||
|
if (!accessResult.success) {
|
||||||
|
toast.error('Failed to open directory', {
|
||||||
|
description: accessResult.error || 'Desktop could not grant file access.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const added = addProject(result.path, { id: result.projectId });
|
||||||
|
if (!added) {
|
||||||
|
toast.error('Failed to add project', {
|
||||||
|
description: 'Please select a valid directory path.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Desktop: Error selecting directory:', error);
|
||||||
|
toast.error('Failed to select directory');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDirectoryDialogOpen(true);
|
||||||
|
}, [
|
||||||
|
addProject,
|
||||||
|
hasShownInitialDirectoryPrompt,
|
||||||
|
isHomeReady,
|
||||||
|
projects.length,
|
||||||
|
requestAccess,
|
||||||
|
startAccessing,
|
||||||
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isSessionCreateDialogOpen) {
|
if (!isSessionCreateDialogOpen) {
|
||||||
|
setWorktreeManagerProjectId(null);
|
||||||
setWorktreeCreateMode('new');
|
setWorktreeCreateMode('new');
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
setExistingWorktreeBranch('');
|
setExistingWorktreeBranch('');
|
||||||
@@ -372,7 +445,9 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return sessionEvents.onCreateRequest(() => {
|
return sessionEvents.onCreateRequest((request) => {
|
||||||
|
const projectId = typeof request?.projectId === 'string' && request.projectId.trim() ? request.projectId : null;
|
||||||
|
setWorktreeManagerProjectId(projectId);
|
||||||
setWorktreeCreateMode('new');
|
setWorktreeCreateMode('new');
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
setExistingWorktreeBranch('');
|
setExistingWorktreeBranch('');
|
||||||
@@ -555,6 +630,9 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
setSessionDirectory(session.id, metadata.path);
|
setSessionDirectory(session.id, metadata.path);
|
||||||
setWorktreeMetadata(session.id, createdMetadata);
|
setWorktreeMetadata(session.id, createdMetadata);
|
||||||
|
|
||||||
|
// Ensure directory-scoped caches and session lists include the new worktree.
|
||||||
|
setDirectory(metadata.path, { showOverlay: false });
|
||||||
|
|
||||||
await refreshWorktrees();
|
await refreshWorktrees();
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
setExistingWorktreeBranch('');
|
setExistingWorktreeBranch('');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,13 +34,13 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("relative flex flex-col min-h-0 w-full overflow-hidden", outerClassName)}
|
className={cn("relative flex flex-col min-h-0 w-full overflow-hidden overscroll-none", outerClassName)}
|
||||||
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
|
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<Component
|
<Component
|
||||||
ref={containerRef as React.Ref<HTMLElement>}
|
ref={containerRef as React.Ref<HTMLElement>}
|
||||||
className={cn(
|
className={cn(
|
||||||
"overlay-scrollbar-target overlay-scrollbar-container",
|
"overlay-scrollbar-target overlay-scrollbar-container overscroll-none",
|
||||||
fillContainer ? "flex-1 min-h-0 w-full h-full" : "flex-none w-full h-auto",
|
fillContainer ? "flex-1 min-h-0 w-full h-full" : "flex-none w-full h-auto",
|
||||||
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -2,8 +2,20 @@ import React from 'react';
|
|||||||
import { cn, getModifierLabel } from '@/lib/utils';
|
import { cn, getModifierLabel } from '@/lib/utils';
|
||||||
import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
|
import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
|
||||||
import type { SidebarSection } from '@/constants/sidebar';
|
import type { SidebarSection } from '@/constants/sidebar';
|
||||||
import { RiArrowLeftSLine, RiCloseLine } from '@remixicon/react';
|
import { RiArrowDownSLine, RiArrowLeftSLine, RiCloseLine, RiFolderLine } from '@remixicon/react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||||
|
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||||
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
|
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||||
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
|
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
|
||||||
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
|
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
|
||||||
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
|
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
|
||||||
@@ -99,8 +111,61 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
|
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
||||||
|
|
||||||
|
const sortedProjects = React.useMemo(() => {
|
||||||
|
return [...projects].sort((a, b) => (a.label || a.path).localeCompare(b.label || b.path));
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const activeProject = React.useMemo(() => {
|
||||||
|
if (sortedProjects.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0];
|
||||||
|
}, [activeProjectId, sortedProjects]);
|
||||||
|
|
||||||
|
// Format project label: kebab-case/snake_case → Title Case
|
||||||
|
const formatProjectLabel = React.useCallback((label: string): string => {
|
||||||
|
return label
|
||||||
|
.replace(/[-_]/g, ' ')
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeProjectLabel = React.useMemo(() => {
|
||||||
|
if (!activeProject) {
|
||||||
|
return 'Project';
|
||||||
|
}
|
||||||
|
const rawLabel = activeProject.label && activeProject.label.trim().length > 0
|
||||||
|
? activeProject.label
|
||||||
|
: (activeProject.path.split('/').filter(Boolean).pop() || activeProject.path);
|
||||||
|
return formatProjectLabel(rawLabel);
|
||||||
|
}, [activeProject, formatProjectLabel]);
|
||||||
|
|
||||||
|
const showProjectSwitcher = sortedProjects.length > 0;
|
||||||
|
|
||||||
const showTabLabels = containerWidth === 0 || containerWidth >= TAB_LABELS_MIN_WIDTH;
|
const showTabLabels = containerWidth === 0 || containerWidth >= TAB_LABELS_MIN_WIDTH;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Force reload when activeProject changes to ensure scopes update
|
||||||
|
if (activeTab === 'agents') {
|
||||||
|
// Small delay to allow store state to propagate if needed
|
||||||
|
setTimeout(() => void useAgentsStore.getState().loadAgents(), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'commands') {
|
||||||
|
setTimeout(() => void useCommandsStore.getState().loadCommands(), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'skills') {
|
||||||
|
void useSkillsStore.getState().loadSkills();
|
||||||
|
void useSkillsCatalogStore.getState().loadCatalog();
|
||||||
|
}
|
||||||
|
}, [activeProjectId, activeTab]);
|
||||||
|
|
||||||
// Update proportional width on window resize (if not manually resized)
|
// Update proportional width on window resize (if not manually resized)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -341,26 +406,79 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{onClose && (
|
{(onClose || showProjectSwitcher) && (
|
||||||
<div className={cn('flex items-center', isMobile ? '' : 'pr-3')}>
|
<div className={cn('flex items-center gap-2', isMobile ? '' : 'pr-3')}>
|
||||||
<Tooltip delayDuration={500}>
|
{showProjectSwitcher && (
|
||||||
<TooltipTrigger asChild>
|
<DropdownMenu>
|
||||||
<button
|
<DropdownMenuTrigger asChild>
|
||||||
type="button"
|
{isMobile ? (
|
||||||
onClick={onClose}
|
<button
|
||||||
aria-label="Close settings"
|
type="button"
|
||||||
className={cn(
|
aria-label="Switch project"
|
||||||
'inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
title={activeProjectLabel}
|
||||||
!isMobile && 'app-region-no-drag'
|
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
<RiFolderLine className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Switch project"
|
||||||
|
title={activeProjectLabel}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 max-w-[18rem] items-center gap-1.5 bg-transparent px-2 text-foreground outline-none hover:text-foreground/80 focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
!isMobile && 'app-region-no-drag'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium">{activeProjectLabel}</span>
|
||||||
|
<RiArrowDownSLine className="size-4 opacity-50" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
>
|
</DropdownMenuTrigger>
|
||||||
<RiCloseLine className="h-5 w-5" />
|
<DropdownMenuContent align="end" className="w-auto">
|
||||||
</button>
|
<DropdownMenuRadioGroup
|
||||||
</TooltipTrigger>
|
value={activeProject?.id ?? ''}
|
||||||
<TooltipContent>
|
onValueChange={(value) => {
|
||||||
<p>Close Settings ({shortcutKey}+,)</p>
|
if (!value) return;
|
||||||
</TooltipContent>
|
setActiveProject(value);
|
||||||
</Tooltip>
|
}}
|
||||||
|
>
|
||||||
|
{sortedProjects.map((project) => {
|
||||||
|
const rawLabel = project.label?.trim()
|
||||||
|
? project.label.trim()
|
||||||
|
: (project.path.split('/').filter(Boolean).pop() || project.path);
|
||||||
|
const label = formatProjectLabel(rawLabel);
|
||||||
|
return (
|
||||||
|
<DropdownMenuRadioItem key={project.id} value={project.id}>
|
||||||
|
<span className="min-w-0 truncate typography-ui">{label}</span>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onClose && (
|
||||||
|
<Tooltip delayDuration={500}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close settings"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||||
|
!isMobile && 'app-region-no-drag'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiCloseLine className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Close Settings ({shortcutKey}+,)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
|
RiMore2Line,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||||
@@ -11,6 +13,14 @@ import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/
|
|||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { ChatContainer } from '@/components/chat/ChatContainer';
|
import { ChatContainer } from '@/components/chat/ChatContainer';
|
||||||
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
|
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -27,8 +37,10 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
group,
|
group,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const { selectedSessionId, selectSession } = useAgentGroupsStore();
|
const { selectedSessionId, selectSession, deleteGroupWorktree, keepOnlyGroupWorktree } = useAgentGroupsStore();
|
||||||
const { setCurrentSession, currentSessionId } = useSessionStore();
|
const { setCurrentSession, currentSessionId } = useSessionStore();
|
||||||
|
const [worktreeDialog, setWorktreeDialog] = React.useState<null | { kind: 'remove' | 'keepOnly'; path: string; label: string }>(null);
|
||||||
|
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||||
|
|
||||||
// Find the currently selected session
|
// Find the currently selected session
|
||||||
const selectedSession = React.useMemo(() => {
|
const selectedSession = React.useMemo(() => {
|
||||||
@@ -70,6 +82,47 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
// Check if the current OpenCode session matches the selected agent group session
|
// Check if the current OpenCode session matches the selected agent group session
|
||||||
const isSessionSynced = selectedSession?.id === currentSessionId;
|
const isSessionSynced = selectedSession?.id === currentSessionId;
|
||||||
|
|
||||||
|
const handleRemoveSelectedWorktree = React.useCallback(async () => {
|
||||||
|
if (!selectedSession) return;
|
||||||
|
setWorktreeDialog({ kind: 'remove', path: selectedSession.path, label: selectedSession.displayLabel });
|
||||||
|
}, [selectedSession]);
|
||||||
|
|
||||||
|
const handleKeepOnlySelectedWorktree = React.useCallback(async () => {
|
||||||
|
if (!selectedSession) return;
|
||||||
|
setWorktreeDialog({ kind: 'keepOnly', path: selectedSession.path, label: selectedSession.displayLabel });
|
||||||
|
}, [selectedSession]);
|
||||||
|
|
||||||
|
const handleConfirmWorktreeAction = React.useCallback(async () => {
|
||||||
|
if (!worktreeDialog || isProcessing) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
if (worktreeDialog.kind === 'remove') {
|
||||||
|
toast.info('Removing worktree...');
|
||||||
|
const ok = await deleteGroupWorktree(group.name, worktreeDialog.path);
|
||||||
|
if (ok) {
|
||||||
|
toast.success('Worktree removed');
|
||||||
|
} else {
|
||||||
|
const error = useAgentGroupsStore.getState().error;
|
||||||
|
toast.error(error || 'Failed to remove worktree');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.info('Removing other worktrees...');
|
||||||
|
const ok = await keepOnlyGroupWorktree(group.name, worktreeDialog.path);
|
||||||
|
if (ok) {
|
||||||
|
toast.success('Removed other worktrees');
|
||||||
|
} else {
|
||||||
|
const error = useAgentGroupsStore.getState().error;
|
||||||
|
toast.error(error || 'Failed to remove other worktrees');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWorktreeDialog(null);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [deleteGroupWorktree, group.name, isProcessing, keepOnlyGroupWorktree, worktreeDialog]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col bg-background', className)}>
|
<div className={cn('flex h-full flex-col bg-background', className)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -90,7 +143,8 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
|
|
||||||
{/* Model Selector Dropdown */}
|
{/* Model Selector Dropdown */}
|
||||||
{group.sessions.length > 0 && (
|
{group.sessions.length > 0 && (
|
||||||
<div className="mt-3">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -154,9 +208,64 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-10 w-10 flex-shrink-0" aria-label="Worktree actions">
|
||||||
|
<RiMore2Line className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-[220px]">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleRemoveSelectedWorktree();
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Remove this worktree
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleKeepOnlySelectedWorktree();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Leave this one, remove others
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(worktreeDialog)} onOpenChange={(open) => { if (!open) setWorktreeDialog(null); }}>
|
||||||
|
<DialogContent className="max-w-md" keyboardAvoid>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{worktreeDialog?.kind === 'remove' ? 'Remove worktree' : 'Remove other worktrees'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{worktreeDialog?.kind === 'remove'
|
||||||
|
? <>Remove <span className="text-foreground font-medium">{worktreeDialog?.label}</span>? This deletes all sessions in that worktree and removes the worktree itself.</>
|
||||||
|
: <>Keep <span className="text-foreground font-medium">{worktreeDialog?.label}</span> and remove the other worktrees in <span className="text-foreground font-medium">{group.name}</span>.</>}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setWorktreeDialog(null)} disabled={isProcessing}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={worktreeDialog?.kind === 'remove' ? 'destructive' : 'default'}
|
||||||
|
onClick={() => void handleConfirmWorktreeAction()}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Working…' : worktreeDialog?.kind === 'remove' ? 'Remove' : 'Remove others'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Chat Content */}
|
{/* Chat Content */}
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|||||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect';
|
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect';
|
||||||
import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector';
|
import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector';
|
||||||
import { AgentSelector } from '@/components/multirun/AgentSelector';
|
import { AgentSelector } from '@/components/multirun/AgentSelector';
|
||||||
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
|
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
|
||||||
|
|
||||||
/** Max file size in bytes (10MB) */
|
/** Max file size in bytes (10MB) */
|
||||||
@@ -23,16 +24,6 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|||||||
/** Max number of concurrent runs */
|
/** Max number of concurrent runs */
|
||||||
const MAX_MODELS = 5;
|
const MAX_MODELS = 5;
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects if a keyboard event is part of IME composition.
|
|
||||||
* Uses both isComposing and keyCode === 229 (MDN recommended).
|
|
||||||
* WebKit may fire compositionend before keydown, causing isComposing to be false
|
|
||||||
* while keyCode remains 229, so both checks are needed.
|
|
||||||
*/
|
|
||||||
const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => {
|
|
||||||
return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Attached file for agent manager */
|
/** Attached file for agent manager */
|
||||||
interface AttachedFile {
|
interface AttachedFile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -191,7 +182,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
|||||||
if (isIMECompositionEvent(e)) return;
|
if (isIMECompositionEvent(e)) return;
|
||||||
|
|
||||||
// Enter submits if valid, Shift+Enter adds newline
|
// Enter submits if valid, Shift+Enter adds newline
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !isIMECompositionEvent(e)) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isValid && !isSubmittingOrCreating) {
|
if (isValid && !isSubmittingOrCreating) {
|
||||||
handleSubmit(e as unknown as React.FormEvent);
|
handleSubmit(e as unknown as React.FormEvent);
|
||||||
@@ -247,7 +238,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
|||||||
onChange={setSelectedAgent}
|
onChange={setSelectedAgent}
|
||||||
/>
|
/>
|
||||||
<p className="typography-micro text-muted-foreground">
|
<p className="typography-micro text-muted-foreground">
|
||||||
Optional agent to use for all runs
|
Defaults to your configured default agent
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ import { toast } from 'sonner';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -24,6 +18,12 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
|
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
@@ -50,121 +50,105 @@ interface AgentGroupItemProps {
|
|||||||
|
|
||||||
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSelect }) => {
|
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSelect }) => {
|
||||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
|
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||||
const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup);
|
const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDeleteGroup = React.useCallback(async () => {
|
||||||
|
if (isDeleting) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
toast.info(`Deleting "${group.name}"...`);
|
||||||
const { success, deletedCount, failedCount } = await deleteGroup(group.name);
|
const ok = await deleteGroup(group.name);
|
||||||
|
if (ok) {
|
||||||
if (success) {
|
toast.success(`Deleted "${group.name}"`);
|
||||||
toast.success(`Deleted agent group "${group.name}"`, {
|
} else {
|
||||||
description: `${deletedCount} session${deletedCount !== 1 ? 's' : ''} removed with worktrees archived.`,
|
const error = useAgentGroupsStore.getState().error;
|
||||||
});
|
toast.error(error || `Failed to delete "${group.name}"`);
|
||||||
} else if (deletedCount > 0) {
|
|
||||||
toast.warning(`Partially deleted agent group "${group.name}"`, {
|
|
||||||
description: `${deletedCount} deleted, ${failedCount} failed.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.error(`Failed to delete agent group "${group.name}"`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(`Failed to delete agent group "${group.name}"`);
|
|
||||||
console.error('Delete group error:', error);
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
}
|
}
|
||||||
};
|
setIsDeleting(false);
|
||||||
|
setConfirmOpen(false);
|
||||||
|
}, [deleteGroup, group.name, isDeleting]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<div
|
||||||
'group relative flex items-center rounded-md px-1.5 py-1.5 cursor-pointer',
|
className={cn(
|
||||||
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6',
|
'group relative flex items-center rounded-md px-1.5 py-1.5 cursor-pointer',
|
||||||
)}
|
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6',
|
||||||
onClick={onSelect}
|
)}
|
||||||
>
|
onClick={onSelect}
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
>
|
||||||
<button
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
type="button"
|
||||||
>
|
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
<span className="truncate typography-ui-label font-normal text-foreground">
|
>
|
||||||
{group.name}
|
<span className="truncate typography-ui-label font-normal text-foreground">
|
||||||
</span>
|
{group.name}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
|
|
||||||
<RiGitBranchLine className="h-3 w-3" />
|
|
||||||
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
<span className="typography-micro text-muted-foreground/60">
|
|
||||||
{formatRelativeTime(group.lastActive)}
|
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
|
||||||
|
<RiGitBranchLine className="h-3 w-3" />
|
||||||
|
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="typography-micro text-muted-foreground/60">
|
||||||
|
{formatRelativeTime(group.lastActive)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 self-stretch">
|
||||||
|
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-3.5 w-[18px] items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||||
|
'opacity-0 group-hover:opacity-100',
|
||||||
|
menuOpen && 'opacity-100',
|
||||||
|
)}
|
||||||
|
aria-label="Group menu"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<RiMore2Line className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-[140px]">
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMenuOpen(false);
|
||||||
|
setConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 self-stretch">
|
|
||||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-3.5 w-[18px] items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
|
||||||
'opacity-0 group-hover:opacity-100',
|
|
||||||
menuOpen && 'opacity-100',
|
|
||||||
)}
|
|
||||||
aria-label="Group menu"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<RiMore2Line className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-[140px]">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMenuOpen(false);
|
|
||||||
setShowDeleteConfirm(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
<DialogContent showCloseButton={!isDeleting}>
|
<DialogContent className="max-w-md" keyboardAvoid>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Agent Group</DialogTitle>
|
<DialogTitle>Delete agent group</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete "{group.name}"? This will remove {group.sessionCount} session{group.sessionCount !== 1 ? 's' : ''} and archive their worktrees. This action cannot be undone.
|
Delete <span className="text-foreground font-medium">{group.name}</span>? This removes all worktrees and sessions in this group.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setConfirmOpen(false)} disabled={isDeleting}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="destructive" onClick={() => void handleDeleteGroup()} disabled={isDeleting}>
|
||||||
variant="destructive"
|
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { AgentGroupDetail } from './AgentGroupDetail';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore';
|
import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore';
|
||||||
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||||
import type { CreateMultiRunParams } from '@/types/multirun';
|
import type { CreateMultiRunParams } from '@/types/multirun';
|
||||||
|
|
||||||
interface AgentManagerViewProps {
|
interface AgentManagerViewProps {
|
||||||
@@ -13,6 +17,25 @@ interface AgentManagerViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className }) => {
|
export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className }) => {
|
||||||
|
const isVSCodeRuntime = Boolean(
|
||||||
|
(typeof window !== 'undefined'
|
||||||
|
? (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } })
|
||||||
|
.__OPENCHAMBER_RUNTIME_APIS__?.runtime?.isVSCode
|
||||||
|
: false)
|
||||||
|
);
|
||||||
|
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
|
||||||
|
() =>
|
||||||
|
(typeof window !== 'undefined'
|
||||||
|
? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
|
||||||
|
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
|
||||||
|
: 'connecting') || 'connecting'
|
||||||
|
);
|
||||||
|
const configInitialized = useConfigStore((state) => state.isInitialized);
|
||||||
|
const initializeApp = useConfigStore((state) => state.initializeApp);
|
||||||
|
const loadSessions = useSessionStore((state) => state.loadSessions);
|
||||||
|
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
||||||
|
const bootstrapAttemptAt = React.useRef<number>(0);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedGroupName,
|
selectedGroupName,
|
||||||
selectGroup,
|
selectGroup,
|
||||||
@@ -22,6 +45,86 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
|
|||||||
|
|
||||||
const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore();
|
const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isVSCodeRuntime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current =
|
||||||
|
(typeof window !== 'undefined'
|
||||||
|
? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status
|
||||||
|
: undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined;
|
||||||
|
if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') {
|
||||||
|
setConnectionStatus(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
|
||||||
|
const status = detail?.status;
|
||||||
|
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
|
||||||
|
setConnectionStatus(status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('openchamber:connection-status', handler as EventListener);
|
||||||
|
return () => window.removeEventListener('openchamber:connection-status', handler as EventListener);
|
||||||
|
}, [isVSCodeRuntime]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isVSCodeRuntime || connectionStatus !== 'connected') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - bootstrapAttemptAt.current < 750) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bootstrapAttemptAt.current = now;
|
||||||
|
|
||||||
|
const workspaceFolder = (typeof window !== 'undefined'
|
||||||
|
? (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if (typeof workspaceFolder === 'string' && workspaceFolder.trim().length > 0) {
|
||||||
|
try {
|
||||||
|
setDirectory(workspaceFolder, { showOverlay: false });
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runBootstrap = async () => {
|
||||||
|
try {
|
||||||
|
if (!configInitialized) {
|
||||||
|
await initializeApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
const configState = useConfigStore.getState();
|
||||||
|
if (
|
||||||
|
!configState.isInitialized ||
|
||||||
|
!configState.isConnected ||
|
||||||
|
configState.providers.length === 0 ||
|
||||||
|
configState.agents.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadSessions();
|
||||||
|
|
||||||
|
if (streamDebugEnabled()) {
|
||||||
|
console.log('[OpenChamber][VSCode][agentManager] bootstrap complete', {
|
||||||
|
providers: configState.providers.length,
|
||||||
|
agents: configState.agents.length,
|
||||||
|
sessions: useSessionStore.getState().sessions.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void runBootstrap();
|
||||||
|
}, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, loadSessions, setDirectory]);
|
||||||
|
|
||||||
const handleGroupSelect = React.useCallback((groupName: string) => {
|
const handleGroupSelect = React.useCallback((groupName: string) => {
|
||||||
selectGroup(groupName);
|
selectGroup(groupName);
|
||||||
}, [selectGroup]);
|
}, [selectGroup]);
|
||||||
@@ -38,10 +141,29 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
|
|||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`);
|
toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`);
|
||||||
// Reload groups to pick up the new worktrees and sessions
|
const groupSlug = result.groupSlug;
|
||||||
await loadGroups();
|
|
||||||
// Select the newly created group
|
const waitForGroup = async (attempts = 6) => {
|
||||||
selectGroup(params.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 50));
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
await loadGroups();
|
||||||
|
const groupsState = useAgentGroupsStore.getState();
|
||||||
|
if (groupsState.groups.some((group) => group.name === groupSlug)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh sessions + groups and wait briefly for OpenCode to surface the new worktree sessions.
|
||||||
|
try {
|
||||||
|
await useSessionStore.getState().loadSessions();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForGroup();
|
||||||
|
selectGroup(groupSlug);
|
||||||
} else {
|
} else {
|
||||||
const error = useMultiRunStore.getState().error;
|
const error = useMultiRunStore.getState().error;
|
||||||
toast.error(error || 'Failed to create agent group');
|
toast.error(error || 'Failed to create agent group');
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client';
|
||||||
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import type { Part, Session, Message, Permission } from '@opencode-ai/sdk/v2';
|
import type { Part, Session, Message } from '@opencode-ai/sdk/v2';
|
||||||
|
import type { PermissionRequest } from '@/types/permission';
|
||||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||||
import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
|
import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
|
||||||
|
|
||||||
@@ -93,7 +94,9 @@ export const useEventStream = () => {
|
|||||||
sessions,
|
sessions,
|
||||||
getWorktreeMetadata,
|
getWorktreeMetadata,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
loadSessions
|
loadSessions,
|
||||||
|
updateSession,
|
||||||
|
removeSessionFromStore
|
||||||
} = useSessionStore();
|
} = useSessionStore();
|
||||||
|
|
||||||
const { checkConnection } = useConfigStore();
|
const { checkConnection } = useConfigStore();
|
||||||
@@ -127,6 +130,31 @@ export const useEventStream = () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [activeSessionDirectory, fallbackDirectory]);
|
}, [activeSessionDirectory, fallbackDirectory]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const bootstrapPendingPermissions = async () => {
|
||||||
|
try {
|
||||||
|
const pending = await opencodeClient.listPendingPermissions();
|
||||||
|
if (cancelled || pending.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.forEach((request) => {
|
||||||
|
addPermission(request as unknown as PermissionRequest);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void bootstrapPendingPermissions();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [addPermission]);
|
||||||
|
|
||||||
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
|
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -250,6 +278,7 @@ export const useEventStream = () => {
|
|||||||
const isCleaningUpRef = React.useRef(false);
|
const isCleaningUpRef = React.useRef(false);
|
||||||
const resyncInFlightRef = React.useRef<Promise<void> | null>(null);
|
const resyncInFlightRef = React.useRef<Promise<void> | null>(null);
|
||||||
const lastResyncAtRef = React.useRef(0);
|
const lastResyncAtRef = React.useRef(0);
|
||||||
|
const permissionToastShownRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
|
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
|
||||||
if (typeof document === 'undefined') return 'visible';
|
if (typeof document === 'undefined') return 'visible';
|
||||||
@@ -265,7 +294,6 @@ export const useEventStream = () => {
|
|||||||
const staleCheckIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
const staleCheckIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastEventTimestampRef = React.useRef<number>(Date.now());
|
const lastEventTimestampRef = React.useRef<number>(Date.now());
|
||||||
const isDesktopRuntimeRef = React.useRef<boolean>(false);
|
const isDesktopRuntimeRef = React.useRef<boolean>(false);
|
||||||
const activityStreamAbortControllerRef = React.useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const maybeBootstrapIfStale = React.useCallback(
|
const maybeBootstrapIfStale = React.useCallback(
|
||||||
(reason: string) => {
|
(reason: string) => {
|
||||||
@@ -299,7 +327,7 @@ export const useEventStream = () => {
|
|||||||
}, [currentSessionId]);
|
}, [currentSessionId]);
|
||||||
|
|
||||||
const requestSessionMetadataRefresh = React.useCallback(
|
const requestSessionMetadataRefresh = React.useCallback(
|
||||||
(sessionId: string | undefined | null) => {
|
(sessionId: string | undefined | null, directoryOverride?: string | null) => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -310,9 +338,35 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
timestamps.set(sessionId, now);
|
timestamps.set(sessionId, now);
|
||||||
|
|
||||||
|
const resolveDirectoryForSession = (id: string): string | null => {
|
||||||
|
if (typeof directoryOverride === 'string' && directoryOverride.trim().length > 0) {
|
||||||
|
return directoryOverride.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = getWorktreeMetadata?.(id);
|
||||||
|
if (metadata?.path) {
|
||||||
|
return metadata.path;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRecord = sessions.find((entry) => entry.id === id) as Session & { directory?: string | null };
|
||||||
|
if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) {
|
||||||
|
return sessionRecord.directory.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const session = await opencodeClient.getSession(sessionId);
|
const directory = resolveDirectoryForSession(sessionId);
|
||||||
|
const session = directory
|
||||||
|
? await opencodeClient.withDirectory(directory, () => opencodeClient.getSession(sessionId))
|
||||||
|
: await opencodeClient.getSession(sessionId);
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
const patch: Partial<Session> = {};
|
const patch: Partial<Session> = {};
|
||||||
if (typeof session.title === 'string' && session.title.length > 0) {
|
if (typeof session.title === 'string' && session.title.length > 0) {
|
||||||
@@ -330,21 +384,9 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
[applySessionMetadata]
|
[applySessionMetadata, getWorktreeMetadata, sessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestSessionListRefresh = React.useCallback(() => {
|
|
||||||
if (sessionRefreshTimeoutRef.current) return;
|
|
||||||
|
|
||||||
sessionRefreshTimeoutRef.current = setTimeout(() => {
|
|
||||||
sessionRefreshTimeoutRef.current = null;
|
|
||||||
try {
|
|
||||||
void loadSessions();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to refresh sessions after stream completion:', error);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}, [loadSessions]);
|
|
||||||
|
|
||||||
const updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => {
|
const updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => {
|
||||||
const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
||||||
@@ -390,12 +432,28 @@ export const useEventStream = () => {
|
|||||||
sessionStatusLastRefreshAtRef.current = now;
|
sessionStatusLastRefreshAtRef.current = now;
|
||||||
|
|
||||||
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
|
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
|
||||||
|
const observed = new Set<string>();
|
||||||
|
const knownSessionIds = new Set(sessions.map((session) => session.id));
|
||||||
|
|
||||||
Object.entries(statusMap).forEach(([sessionId, raw]) => {
|
Object.entries(statusMap).forEach(([sessionId, raw]) => {
|
||||||
if (!sessionId || !raw) return;
|
if (!sessionId || !raw) return;
|
||||||
|
observed.add(sessionId);
|
||||||
const phase: 'idle' | 'busy' =
|
const phase: 'idle' | 'busy' =
|
||||||
raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle';
|
raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle';
|
||||||
updateSessionActivityPhase(sessionId, phase);
|
updateSessionActivityPhase(sessionId, phase);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OpenCode's /session/status may omit idle sessions (returns only busy/retry).
|
||||||
|
// Treat missing entries as idle to avoid sessions getting stuck "working".
|
||||||
|
const currentPhases = useSessionStore.getState().sessionActivityPhase;
|
||||||
|
if (!currentPhases) return;
|
||||||
|
|
||||||
|
for (const [sessionId, phase] of currentPhases.entries()) {
|
||||||
|
if (!knownSessionIds.has(sessionId)) continue;
|
||||||
|
if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) {
|
||||||
|
updateSessionActivityPhase(sessionId, 'idle');
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const task = (async (): Promise<void> => {
|
const task = (async (): Promise<void> => {
|
||||||
@@ -435,6 +493,19 @@ export const useEventStream = () => {
|
|||||||
Object.assign(merged, result.value);
|
Object.assign(merged, result.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (Object.keys(merged).length === 0) {
|
||||||
|
const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
||||||
|
(phase) => phase === 'busy' || phase === 'cooldown'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasActivePhases) {
|
||||||
|
const healthy = await opencodeClient.checkHealth().catch(() => false);
|
||||||
|
if (!healthy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applyStatusMap(merged);
|
applyStatusMap(merged);
|
||||||
} catch {
|
} catch {
|
||||||
// ignored
|
// ignored
|
||||||
@@ -463,92 +534,6 @@ export const useEventStream = () => {
|
|||||||
previousSessionDirectoryRef.current = nextDirectory;
|
previousSessionDirectoryRef.current = nextDirectory;
|
||||||
}, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]);
|
}, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]);
|
||||||
|
|
||||||
const handleActivityEvent = React.useCallback((event: EventData) => {
|
|
||||||
if (!event?.type) return;
|
|
||||||
|
|
||||||
const props = (event.properties ?? {}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (event.type === 'openchamber:session-activity') {
|
|
||||||
const sessionId =
|
|
||||||
typeof props.sessionId === 'string'
|
|
||||||
? props.sessionId
|
|
||||||
: typeof props.sessionID === 'string'
|
|
||||||
? props.sessionID
|
|
||||||
: null;
|
|
||||||
const phase = typeof props.phase === 'string' ? props.phase : null;
|
|
||||||
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
|
||||||
updateSessionActivityPhase(sessionId, phase);
|
|
||||||
requestSessionListRefresh();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'session.status') {
|
|
||||||
const sessionId =
|
|
||||||
typeof props.sessionID === 'string'
|
|
||||||
? props.sessionID
|
|
||||||
: typeof props.sessionId === 'string'
|
|
||||||
? props.sessionId
|
|
||||||
: null;
|
|
||||||
const statusObj =
|
|
||||||
typeof props.status === 'object' && props.status !== null
|
|
||||||
? (props.status as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const statusType = typeof statusObj?.type === 'string' ? (statusObj.type as string) : null;
|
|
||||||
|
|
||||||
if (sessionId && statusType) {
|
|
||||||
updateSessionActivityPhase(
|
|
||||||
sessionId,
|
|
||||||
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
|
|
||||||
);
|
|
||||||
requestSessionListRefresh();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'session.idle') {
|
|
||||||
const sessionId =
|
|
||||||
typeof props.sessionID === 'string'
|
|
||||||
? props.sessionID
|
|
||||||
: typeof props.sessionId === 'string'
|
|
||||||
? props.sessionId
|
|
||||||
: null;
|
|
||||||
if (sessionId) {
|
|
||||||
updateSessionActivityPhase(sessionId, 'idle');
|
|
||||||
requestSessionListRefresh();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'message.updated' || event.type === 'message.part.updated') {
|
|
||||||
const messageInfo =
|
|
||||||
typeof props.info === 'object' && props.info !== null ? (props.info as Record<string, unknown>) : props;
|
|
||||||
|
|
||||||
const sessionId =
|
|
||||||
typeof (messageInfo as { sessionID?: unknown }).sessionID === 'string'
|
|
||||||
? (messageInfo as { sessionID?: string }).sessionID
|
|
||||||
: typeof (messageInfo as { sessionId?: unknown }).sessionId === 'string'
|
|
||||||
? (messageInfo as { sessionId?: string }).sessionId
|
|
||||||
: typeof props.sessionID === 'string'
|
|
||||||
? (props.sessionID as string)
|
|
||||||
: typeof props.sessionId === 'string'
|
|
||||||
? (props.sessionId as string)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const role = (messageInfo as { role?: unknown }).role;
|
|
||||||
const finish = (messageInfo as { finish?: unknown }).finish;
|
|
||||||
|
|
||||||
if (sessionId && role === 'assistant' && finish === 'stop') {
|
|
||||||
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
|
||||||
if (currentPhase === 'busy') {
|
|
||||||
updateSessionActivityPhase(sessionId, 'cooldown');
|
|
||||||
requestSessionListRefresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [requestSessionListRefresh, updateSessionActivityPhase]);
|
|
||||||
|
|
||||||
const handleEvent = React.useCallback((event: EventData) => {
|
const handleEvent = React.useCallback((event: EventData) => {
|
||||||
lastEventTimestampRef.current = Date.now();
|
lastEventTimestampRef.current = Date.now();
|
||||||
|
|
||||||
@@ -603,8 +588,7 @@ export const useEventStream = () => {
|
|||||||
const phase = typeof props.phase === 'string' ? props.phase : null;
|
const phase = typeof props.phase === 'string' ? props.phase : null;
|
||||||
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
||||||
updateSessionActivityPhase(sessionId, phase);
|
updateSessionActivityPhase(sessionId, phase);
|
||||||
// Refresh session list on activity changes (same trigger as activity indication)
|
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
||||||
requestSessionListRefresh();
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -621,8 +605,7 @@ export const useEventStream = () => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
|
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
|
||||||
);
|
);
|
||||||
// Refresh session list on status changes (same trigger as activity indication)
|
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
||||||
requestSessionListRefresh();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1009,11 +992,15 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
||||||
const messageSessionId: string =
|
const messageSessionId: string =
|
||||||
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
|
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
|
||||||
? rawMessageSessionId
|
? rawMessageSessionId
|
||||||
: sessionId;
|
: sessionId;
|
||||||
requestSessionMetadataRefresh(messageSessionId);
|
requestSessionMetadataRefresh(
|
||||||
|
messageSessionId,
|
||||||
|
typeof props.directory === 'string' ? props.directory : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
const summaryInfo = message as Message & { summary?: boolean };
|
const summaryInfo = message as Message & { summary?: boolean };
|
||||||
if (summaryInfo.summary && typeof messageSessionId === 'string') {
|
if (summaryInfo.summary && typeof messageSessionId === 'string') {
|
||||||
@@ -1023,6 +1010,7 @@ export const useEventStream = () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'session.created':
|
||||||
case 'session.updated': {
|
case 'session.updated': {
|
||||||
const candidate = (typeof props.info === 'object' && props.info !== null) ? props.info as Record<string, unknown> :
|
const candidate = (typeof props.info === 'object' && props.info !== null) ? props.info as Record<string, unknown> :
|
||||||
(typeof props.sessionInfo === 'object' && props.sessionInfo !== null) ? props.sessionInfo as Record<string, unknown> :
|
(typeof props.sessionInfo === 'object' && props.sessionInfo !== null) ? props.sessionInfo as Record<string, unknown> :
|
||||||
@@ -1038,6 +1026,32 @@ export const useEventStream = () => {
|
|||||||
(typeof props.time === 'object' && props.time !== null) ? props.time as Record<string, unknown> : null;
|
(typeof props.time === 'object' && props.time !== null) ? props.time as Record<string, unknown> : null;
|
||||||
const compactingTimestamp = timeSource && typeof timeSource.compacting === 'number' ? timeSource.compacting as number : null;
|
const compactingTimestamp = timeSource && typeof timeSource.compacting === 'number' ? timeSource.compacting as number : null;
|
||||||
updateSessionCompaction(sessionId, compactingTimestamp);
|
updateSessionCompaction(sessionId, compactingTimestamp);
|
||||||
|
|
||||||
|
const sessionDirectory = typeof (candidate as { directory?: unknown }).directory === 'string'
|
||||||
|
? (candidate as { directory: string }).directory
|
||||||
|
: typeof props.directory === 'string'
|
||||||
|
? (props.directory as string)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const patchedSession = {
|
||||||
|
...(candidate as unknown as Record<string, unknown>),
|
||||||
|
id: sessionId,
|
||||||
|
...(sessionDirectory ? { directory: sessionDirectory } : {}),
|
||||||
|
} as unknown as Session;
|
||||||
|
|
||||||
|
updateSession(patchedSession);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'session.deleted': {
|
||||||
|
const sessionId = typeof props.sessionID === 'string'
|
||||||
|
? props.sessionID
|
||||||
|
: typeof props.id === 'string'
|
||||||
|
? props.id
|
||||||
|
: null;
|
||||||
|
if (sessionId) {
|
||||||
|
removeSessionFromStore(sessionId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1058,56 +1072,65 @@ export const useEventStream = () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'permission.updated':
|
case 'permission.asked': {
|
||||||
if (currentSessionId === props.sessionID) {
|
if (!('sessionID' in props) || typeof props.sessionID !== 'string') {
|
||||||
addPermission(props as unknown as Permission);
|
break;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
|
||||||
case 'permission.asked':
|
const request = props as unknown as PermissionRequest;
|
||||||
// New permission system from OpenCode's PermissionNext
|
|
||||||
if ('sessionID' in props && props.sessionID === currentSessionId) {
|
|
||||||
const askedProps = props as {
|
|
||||||
id: string;
|
|
||||||
permission: string;
|
|
||||||
sessionID: string;
|
|
||||||
patterns?: string[];
|
|
||||||
always?: string[];
|
|
||||||
metadata: Record<string, unknown>;
|
|
||||||
tool?: {
|
|
||||||
messageID: string;
|
|
||||||
callID: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert new permission.asked event format to Permission type
|
addPermission(request);
|
||||||
const permission = {
|
|
||||||
id: askedProps.id,
|
// Notify if permission is for another session (common with child sessions).
|
||||||
type: askedProps.permission,
|
const toastKey = `${request.sessionID}:${request.id}`;
|
||||||
pattern: askedProps.patterns, // Map patterns to pattern field for compatibility
|
if (!permissionToastShownRef.current.has(toastKey)) {
|
||||||
sessionID: askedProps.sessionID,
|
setTimeout(() => {
|
||||||
messageID: askedProps.tool?.messageID || askedProps.sessionID,
|
const current = currentSessionIdRef.current;
|
||||||
callID: askedProps.tool?.callID,
|
if (current === request.sessionID) {
|
||||||
title: `${askedProps.permission} permission required`,
|
return;
|
||||||
metadata: {
|
}
|
||||||
...askedProps.metadata,
|
|
||||||
always: askedProps.always, // Store always in metadata for UI access
|
const pending = useSessionStore
|
||||||
patterns: askedProps.patterns,
|
.getState()
|
||||||
},
|
.permissions
|
||||||
time: { created: Date.now() },
|
.get(request.sessionID)
|
||||||
} as unknown as Permission;
|
?.some((entry) => entry.id === request.id);
|
||||||
addPermission(permission);
|
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionToastShownRef.current.add(toastKey);
|
||||||
|
|
||||||
|
const sessionTitle =
|
||||||
|
useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title ||
|
||||||
|
'Session';
|
||||||
|
|
||||||
|
import('sonner').then(({ toast }) => {
|
||||||
|
toast.warning('Permission required', {
|
||||||
|
description: sessionTitle,
|
||||||
|
action: {
|
||||||
|
label: 'Open',
|
||||||
|
onClick: () => {
|
||||||
|
useUIStore.getState().setActiveMainTab('chat');
|
||||||
|
void useSessionStore.getState().setCurrentSession(request.sessionID);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'permission.replied':
|
case 'permission.replied':
|
||||||
// Permission was responded to - UI will update via permissionStore
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'todo.updated': {
|
case 'todo.updated': {
|
||||||
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
||||||
const todos = Array.isArray(props.todos) ? props.todos : [];
|
const todos = Array.isArray(props.todos) ? props.todos : null;
|
||||||
if (sessionId && todos.length > 0) {
|
if (sessionId && todos) {
|
||||||
handleTodoUpdatedEvent(
|
handleTodoUpdatedEvent(
|
||||||
sessionId,
|
sessionId,
|
||||||
todos as Array<{ id: string; content: string; status: string; priority: string }>
|
todos as Array<{ id: string; content: string; status: string; priority: string }>
|
||||||
@@ -1124,12 +1147,13 @@ export const useEventStream = () => {
|
|||||||
addPermission,
|
addPermission,
|
||||||
checkConnection,
|
checkConnection,
|
||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
requestSessionListRefresh,
|
|
||||||
updateSessionCompaction,
|
updateSessionCompaction,
|
||||||
applySessionMetadata,
|
applySessionMetadata,
|
||||||
trackMessage,
|
trackMessage,
|
||||||
reportMessage,
|
reportMessage,
|
||||||
updateSessionActivityPhase,
|
updateSessionActivityPhase,
|
||||||
|
updateSession,
|
||||||
|
removeSessionFromStore,
|
||||||
bootstrapState
|
bootstrapState
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1144,7 +1168,6 @@ export const useEventStream = () => {
|
|||||||
console.debug('[useEventStream] Connection state:', {
|
console.debug('[useEventStream] Connection state:', {
|
||||||
isDesktopRuntime: isDesktopRuntimeRef.current,
|
isDesktopRuntime: isDesktopRuntimeRef.current,
|
||||||
hasUnsubscribe: Boolean(unsubscribeRef.current),
|
hasUnsubscribe: Boolean(unsubscribeRef.current),
|
||||||
hasActivityStream: Boolean(activityStreamAbortControllerRef.current),
|
|
||||||
currentSessionId: currentSessionIdRef.current,
|
currentSessionId: currentSessionIdRef.current,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
onlineStatus: onlineStatusRef.current,
|
onlineStatus: onlineStatusRef.current,
|
||||||
@@ -1183,14 +1206,6 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activityStreamAbortControllerRef.current) {
|
|
||||||
try {
|
|
||||||
activityStreamAbortControllerRef.current.abort();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[useEventStream] Error during activity stream abort:', error);
|
|
||||||
}
|
|
||||||
activityStreamAbortControllerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
isCleaningUpRef.current = false;
|
isCleaningUpRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1239,9 +1254,9 @@ export const useEventStream = () => {
|
|||||||
lastEventTimestampRef.current = Date.now();
|
lastEventTimestampRef.current = Date.now();
|
||||||
publishStatus('connected', null);
|
publishStatus('connected', null);
|
||||||
checkConnection();
|
checkConnection();
|
||||||
|
|
||||||
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
||||||
(phase) => phase === 'busy'
|
(phase) => phase === 'busy' || phase === 'cooldown'
|
||||||
);
|
);
|
||||||
if (hasBusySessions) {
|
if (hasBusySessions) {
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionActivityStatus();
|
||||||
@@ -1278,143 +1293,29 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sdkUnsub = opencodeClient.subscribeToEvents(
|
const sdkUnsub = opencodeClient.subscribeToGlobalEvents(
|
||||||
handleEvent,
|
(event: RoutedOpencodeEvent) => {
|
||||||
|
const payload = event.payload as unknown as EventData;
|
||||||
|
const payloadRecord = event.payload as unknown as Record<string, unknown>;
|
||||||
|
const baseProperties =
|
||||||
|
typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null
|
||||||
|
? (payloadRecord.properties as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const properties =
|
||||||
|
event.directory && event.directory !== 'global'
|
||||||
|
? { ...baseProperties, directory: event.directory }
|
||||||
|
: baseProperties;
|
||||||
|
|
||||||
|
handleEvent({
|
||||||
|
type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '',
|
||||||
|
properties,
|
||||||
|
});
|
||||||
|
},
|
||||||
onError,
|
onError,
|
||||||
onOpen,
|
onOpen,
|
||||||
effectiveDirectory,
|
|
||||||
{ scope: 'directory', key: 'events' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isDesktopRuntimeRef.current) {
|
|
||||||
if (activityStreamAbortControllerRef.current) {
|
|
||||||
activityStreamAbortControllerRef.current.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
const activityAbortController = new AbortController();
|
|
||||||
activityStreamAbortControllerRef.current = activityAbortController;
|
|
||||||
|
|
||||||
const parseSseEventBlock = (block: string): EventData | null => {
|
|
||||||
if (!block) return null;
|
|
||||||
|
|
||||||
const dataLines = block
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => line.startsWith('data:'))
|
|
||||||
.map((line) => line.slice(5).replace(/^\s/, ''));
|
|
||||||
|
|
||||||
if (dataLines.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadText = dataLines.join('\n').trim();
|
|
||||||
if (!payloadText) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(payloadText) as unknown;
|
|
||||||
if (!parsed || typeof parsed !== 'object') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = parsed as Record<string, unknown>;
|
|
||||||
if (typeof record.type === 'string') {
|
|
||||||
return record as unknown as EventData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nestedPayload = record.payload;
|
|
||||||
if (nestedPayload && typeof nestedPayload === 'object') {
|
|
||||||
const nestedRecord = nestedPayload as Record<string, unknown>;
|
|
||||||
if (typeof nestedRecord.type === 'string') {
|
|
||||||
return nestedRecord as unknown as EventData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const candidateEndpoints = ['/api/global/event', '/api/event'];
|
|
||||||
let response: Response | null = null;
|
|
||||||
let lastError: unknown = null;
|
|
||||||
|
|
||||||
for (const endpoint of candidateEndpoints) {
|
|
||||||
try {
|
|
||||||
const candidateResponse = await fetch(endpoint, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
},
|
|
||||||
signal: activityAbortController.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (candidateResponse.ok && candidateResponse.body) {
|
|
||||||
response = candidateResponse;
|
|
||||||
if (streamDebugEnabled()) {
|
|
||||||
console.info('[useEventStream] Activity stream connected:', endpoint);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastError = new Error(`Activity stream failed: ${candidateResponse.status}`);
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw lastError ?? new Error('Activity stream failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseBody = response.body;
|
|
||||||
if (!responseBody) {
|
|
||||||
throw new Error('Activity stream missing body');
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = responseBody.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
if (activityAbortController.signal.aborted) break;
|
|
||||||
if (!value || value.length === 0) continue;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
|
||||||
const blocks = buffer.split('\n\n');
|
|
||||||
buffer = blocks.pop() ?? '';
|
|
||||||
for (const block of blocks) {
|
|
||||||
const event = parseSseEventBlock(block);
|
|
||||||
if (event) {
|
|
||||||
handleActivityEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = buffer.trim();
|
|
||||||
if (remaining) {
|
|
||||||
const event = parseSseEventBlock(remaining);
|
|
||||||
if (event) {
|
|
||||||
handleActivityEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!activityAbortController.signal.aborted) {
|
|
||||||
console.warn('[useEventStream] Activity stream error:', error);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (activityStreamAbortControllerRef.current === activityAbortController) {
|
|
||||||
activityStreamAbortControllerRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
const compositeUnsub = () => {
|
const compositeUnsub = () => {
|
||||||
try {
|
try {
|
||||||
@@ -1428,10 +1329,6 @@ export const useEventStream = () => {
|
|||||||
unsubscribeRef.current = compositeUnsub;
|
unsubscribeRef.current = compositeUnsub;
|
||||||
} else {
|
} else {
|
||||||
compositeUnsub();
|
compositeUnsub();
|
||||||
if (activityStreamAbortControllerRef.current) {
|
|
||||||
activityStreamAbortControllerRef.current.abort();
|
|
||||||
activityStreamAbortControllerRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (subscriptionError) {
|
} catch (subscriptionError) {
|
||||||
console.error('[useEventStream] Error during subscription:', subscriptionError);
|
console.error('[useEventStream] Error during subscription:', subscriptionError);
|
||||||
@@ -1445,7 +1342,6 @@ export const useEventStream = () => {
|
|||||||
resyncMessages,
|
resyncMessages,
|
||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
handleEvent,
|
handleEvent,
|
||||||
handleActivityEvent,
|
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
refreshSessionActivityStatus,
|
refreshSessionActivityStatus,
|
||||||
waitForDesktopBridge,
|
waitForDesktopBridge,
|
||||||
@@ -1499,8 +1395,7 @@ export const useEventStream = () => {
|
|||||||
const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null;
|
const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null;
|
||||||
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
||||||
updateSessionActivityPhase(sessionId, phase);
|
updateSessionActivityPhase(sessionId, phase);
|
||||||
// Refresh session list on activity changes (same trigger as activity indication)
|
requestSessionMetadataRefresh(sessionId);
|
||||||
requestSessionListRefresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
|
window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
|
||||||
@@ -1543,10 +1438,9 @@ export const useEventStream = () => {
|
|||||||
resyncMessages(sessionId, 'visibility_restore').catch(() => {});
|
resyncMessages(sessionId, 'visibility_restore').catch(() => {});
|
||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadSessions();
|
void refreshSessionActivityStatus();
|
||||||
void refreshSessionActivityStatus();
|
publishStatus('connecting', 'Resuming stream');
|
||||||
publishStatus('connecting', 'Resuming stream');
|
|
||||||
startStream({ resetAttempts: true });
|
startStream({ resetAttempts: true });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1571,7 +1465,6 @@ export const useEventStream = () => {
|
|||||||
.then(() => console.info('[useEventStream] Messages refreshed on focus'))
|
.then(() => console.info('[useEventStream] Messages refreshed on focus'))
|
||||||
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
|
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
|
||||||
}
|
}
|
||||||
void loadSessions();
|
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionActivityStatus();
|
||||||
|
|
||||||
publishStatus('connecting', 'Resuming stream');
|
publishStatus('connecting', 'Resuming stream');
|
||||||
@@ -1619,7 +1512,7 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
||||||
(phase) => phase === 'busy'
|
(phase) => phase === 'busy' || phase === 'cooldown'
|
||||||
);
|
);
|
||||||
if (hasBusySessions) {
|
if (hasBusySessions) {
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionActivityStatus();
|
||||||
@@ -1692,7 +1585,6 @@ export const useEventStream = () => {
|
|||||||
scheduleReconnect,
|
scheduleReconnect,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
requestSessionListRefresh,
|
|
||||||
updateSessionActivityPhase,
|
updateSessionActivityPhase,
|
||||||
refreshSessionActivityStatus,
|
refreshSessionActivityStatus,
|
||||||
shouldHoldConnection,
|
shouldHoldConnection,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const useFileSystemAccess = () => {
|
|||||||
setIsDesktop(isDesktopRuntime());
|
setIsDesktop(isDesktopRuntime());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; error?: string }> => {
|
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
return { success: true, path: directoryPath };
|
return { success: true, path: directoryPath };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import React from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { isDesktopRuntime } from '@/lib/desktop';
|
import { isDesktopRuntime } from '@/lib/desktop';
|
||||||
|
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||||
|
|
||||||
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
||||||
|
|
||||||
@@ -42,20 +43,36 @@ export const useMenuActions = (
|
|||||||
setSettingsDialogOpen,
|
setSettingsDialogOpen,
|
||||||
setAboutDialogOpen,
|
setAboutDialogOpen,
|
||||||
} = useUIStore();
|
} = useUIStore();
|
||||||
const { setDirectory } = useDirectoryStore();
|
const { addProject } = useProjectsStore();
|
||||||
|
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
const { setThemeMode } = useThemeSystem();
|
const { setThemeMode } = useThemeSystem();
|
||||||
const isDownloadingLogsRef = React.useRef(false);
|
const isDownloadingLogsRef = React.useRef(false);
|
||||||
|
|
||||||
const handleChangeWorkspace = React.useCallback(() => {
|
const handleChangeWorkspace = React.useCallback(() => {
|
||||||
if (isDesktopRuntime() && window.opencodeDesktop?.requestDirectoryAccess) {
|
if (isDesktopRuntime()) {
|
||||||
window.opencodeDesktop
|
requestAccess('')
|
||||||
.requestDirectoryAccess('')
|
.then(async (result) => {
|
||||||
.then((result) => {
|
if (!result.success || !result.path) {
|
||||||
if (result.success && result.path) {
|
if (result.error && result.error !== 'Directory selection cancelled') {
|
||||||
setDirectory(result.path, { showOverlay: true });
|
toast.error('Failed to select directory', {
|
||||||
} else if (result.error && result.error !== 'Directory selection cancelled') {
|
description: result.error,
|
||||||
toast.error('Failed to select directory', {
|
});
|
||||||
description: result.error,
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessResult = await startAccessing(result.path);
|
||||||
|
if (!accessResult.success) {
|
||||||
|
toast.error('Failed to open directory', {
|
||||||
|
description: accessResult.error || 'Desktop could not grant file access.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const added = addProject(result.path, { id: result.projectId });
|
||||||
|
if (!added) {
|
||||||
|
toast.error('Failed to add project', {
|
||||||
|
description: 'Please select a valid directory path.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -66,7 +83,7 @@ export const useMenuActions = (
|
|||||||
} else {
|
} else {
|
||||||
sessionEvents.requestDirectoryDialog();
|
sessionEvents.requestDirectoryDialog();
|
||||||
}
|
}
|
||||||
}, [setDirectory]);
|
}, [addProject, requestAccess, startAccessing]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleMenuAction = (event: Event) => {
|
const handleMenuAction = (event: Event) => {
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ svg.animate-spin {
|
|||||||
--sidebar-accent-foreground: oklch(0.25 0.02 40); /* Dark warm text */
|
--sidebar-accent-foreground: oklch(0.25 0.02 40); /* Dark warm text */
|
||||||
--sidebar-border: oklch(0.85 0.02 70); /* Warm border */
|
--sidebar-border: oklch(0.85 0.02 70); /* Warm border */
|
||||||
--sidebar-ring: oklch(0.65 0.2 55); /* Focus ring */
|
--sidebar-ring: oklch(0.65 0.2 55); /* Focus ring */
|
||||||
|
--sidebar-stuck-bg: #DDDDD3; /* Desktop sidebar sticky header background */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -443,6 +444,7 @@ svg.animate-spin {
|
|||||||
--sidebar-accent-foreground: oklch(0.85 0.02 90); /* #cdccc3 */
|
--sidebar-accent-foreground: oklch(0.85 0.02 90); /* #cdccc3 */
|
||||||
--sidebar-border: oklch(0.31 0.01 35); /* #393836 */
|
--sidebar-border: oklch(0.31 0.01 35); /* #393836 */
|
||||||
--sidebar-ring: oklch(0.77 0.17 85); /* #edb449 */
|
--sidebar-ring: oklch(0.77 0.17 85); /* #edb449 */
|
||||||
|
--sidebar-stuck-bg: #1F1F1D; /* Desktop sidebar sticky header background */
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -322,6 +322,14 @@ export interface FilesAPI {
|
|||||||
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
|
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectEntry {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
label?: string;
|
||||||
|
addedAt?: number;
|
||||||
|
lastOpenedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SettingsPayload {
|
export interface SettingsPayload {
|
||||||
themeId?: string;
|
themeId?: string;
|
||||||
useSystemTheme?: boolean;
|
useSystemTheme?: boolean;
|
||||||
@@ -330,6 +338,8 @@ export interface SettingsPayload {
|
|||||||
darkThemeId?: string;
|
darkThemeId?: string;
|
||||||
lastDirectory?: string;
|
lastDirectory?: string;
|
||||||
homeDirectory?: string;
|
homeDirectory?: string;
|
||||||
|
projects?: ProjectEntry[];
|
||||||
|
activeProjectId?: string;
|
||||||
approvedDirectories?: string[];
|
approvedDirectories?: string[];
|
||||||
securityScopedBookmarks?: string[];
|
securityScopedBookmarks?: string[];
|
||||||
pinnedDirectories?: string[];
|
pinnedDirectories?: string[];
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ProjectEntry } from '@/lib/api/types';
|
||||||
|
|
||||||
export type AssistantNotificationPayload = {
|
export type AssistantNotificationPayload = {
|
||||||
title?: string;
|
title?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
@@ -43,6 +45,8 @@ export type DesktopSettings = {
|
|||||||
darkThemeId?: string;
|
darkThemeId?: string;
|
||||||
lastDirectory?: string;
|
lastDirectory?: string;
|
||||||
homeDirectory?: string;
|
homeDirectory?: string;
|
||||||
|
projects?: ProjectEntry[];
|
||||||
|
activeProjectId?: string;
|
||||||
approvedDirectories?: string[];
|
approvedDirectories?: string[];
|
||||||
securityScopedBookmarks?: string[];
|
securityScopedBookmarks?: string[];
|
||||||
pinnedDirectories?: string[];
|
pinnedDirectories?: string[];
|
||||||
@@ -72,7 +76,7 @@ export type DesktopApi = {
|
|||||||
getHomeDirectory?: () => Promise<{ success: boolean; path: string | null }>;
|
getHomeDirectory?: () => Promise<{ success: boolean; path: string | null }>;
|
||||||
getSettings?: () => Promise<DesktopSettings>;
|
getSettings?: () => Promise<DesktopSettings>;
|
||||||
updateSettings?: (changes: Partial<DesktopSettings>) => Promise<DesktopSettings>;
|
updateSettings?: (changes: Partial<DesktopSettings>) => Promise<DesktopSettings>;
|
||||||
requestDirectoryAccess?: (path: string) => Promise<{ success: boolean; path?: string; error?: string }>;
|
requestDirectoryAccess?: (path: string) => Promise<{ success: boolean; path?: string; projectId?: string; error?: string }>;
|
||||||
startAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
|
startAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
stopAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
|
stopAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
notifyAssistantCompletion?: (payload?: AssistantNotificationPayload) => Promise<{ success: boolean }>;
|
notifyAssistantCompletion?: (payload?: AssistantNotificationPayload) => Promise<{ success: boolean }>;
|
||||||
@@ -205,7 +209,7 @@ export const updateDesktopSettings = async (
|
|||||||
|
|
||||||
export const requestDirectoryAccess = async (
|
export const requestDirectoryAccess = async (
|
||||||
directoryPath: string
|
directoryPath: string
|
||||||
): Promise<{ success: boolean; path?: string; error?: string }> => {
|
): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||||
const api = getDesktopApi();
|
const api = getDesktopApi();
|
||||||
if (!api || !api.requestDirectoryAccess) {
|
if (!api || !api.requestDirectoryAccess) {
|
||||||
return { success: true, path: directoryPath };
|
return { success: true, path: directoryPath };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
FilePartInput,
|
FilePartInput,
|
||||||
Event,
|
Event,
|
||||||
} from "@opencode-ai/sdk/v2";
|
} from "@opencode-ai/sdk/v2";
|
||||||
|
import type { PermissionRequest } from "@/types/permission";
|
||||||
type StreamEvent<TData> = {
|
type StreamEvent<TData> = {
|
||||||
data: TData;
|
data: TData;
|
||||||
event?: string;
|
event?: string;
|
||||||
@@ -20,6 +21,11 @@ type StreamEvent<TData> = {
|
|||||||
retry?: number;
|
retry?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RoutedOpencodeEvent = {
|
||||||
|
directory: string;
|
||||||
|
payload: Event;
|
||||||
|
};
|
||||||
|
|
||||||
// Use relative path by default (works with both dev and nginx proxy server)
|
// Use relative path by default (works with both dev and nginx proxy server)
|
||||||
// Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments
|
// Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments
|
||||||
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api";
|
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api";
|
||||||
@@ -132,6 +138,14 @@ class OpencodeService {
|
|||||||
private sseAbortControllers: Map<string, AbortController> = new Map();
|
private sseAbortControllers: Map<string, AbortController> = new Map();
|
||||||
private currentDirectory: string | undefined = undefined;
|
private currentDirectory: string | undefined = undefined;
|
||||||
|
|
||||||
|
private globalSseAbortController: AbortController | null = null;
|
||||||
|
private globalSseTask: Promise<void> | null = null;
|
||||||
|
private globalSseLastEventId: string | undefined;
|
||||||
|
private globalSseIsConnected = false;
|
||||||
|
private globalSseListeners: Set<(event: RoutedOpencodeEvent) => void> = new Set();
|
||||||
|
private globalSseOpenListeners: Set<() => void> = new Set();
|
||||||
|
private globalSseErrorListeners: Set<(error: unknown) => void> = new Set();
|
||||||
|
|
||||||
constructor(baseUrl: string = DEFAULT_BASE_URL) {
|
constructor(baseUrl: string = DEFAULT_BASE_URL) {
|
||||||
const desktopBase = resolveDesktopBaseUrl();
|
const desktopBase = resolveDesktopBaseUrl();
|
||||||
const requestedBaseUrl = desktopBase || baseUrl;
|
const requestedBaseUrl = desktopBase || baseUrl;
|
||||||
@@ -726,21 +740,46 @@ class OpencodeService {
|
|||||||
return this.getSessionStatusForDirectory(null);
|
return this.getSessionStatusForDirectory(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
async listToolIds(options?: { directory?: string | null }): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const directory = typeof options?.directory === 'string'
|
||||||
|
? options.directory.trim()
|
||||||
|
: (this.currentDirectory ? this.currentDirectory.trim() : '');
|
||||||
|
|
||||||
|
const result = await this.client.tool.ids(directory ? { directory } : undefined);
|
||||||
|
const tools = (result.data || []) as unknown as string[];
|
||||||
|
return tools.filter((tool) => typeof tool === 'string' && tool !== 'invalid');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
async respondToPermission(
|
async replyToPermission(
|
||||||
sessionId: string,
|
requestId: string,
|
||||||
permissionId: string,
|
reply: 'once' | 'always' | 'reject',
|
||||||
response: 'once' | 'always' | 'reject'
|
options?: { message?: string }
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const result = await this.client.permission.respond({
|
const result = await this.client.permission.reply({
|
||||||
sessionID: sessionId,
|
requestID: requestId,
|
||||||
permissionID: permissionId,
|
|
||||||
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
|
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
|
||||||
response
|
reply,
|
||||||
|
...(options?.message ? { message: options.message } : {}),
|
||||||
});
|
});
|
||||||
return result.data || false;
|
return result.data || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listPendingPermissions(): Promise<PermissionRequest[]> {
|
||||||
|
try {
|
||||||
|
// Permission requests are global across sessions; do not scope by directory.
|
||||||
|
const result = await this.client.permission.list();
|
||||||
|
return (result.data || []) as unknown as PermissionRequest[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
async getConfig(): Promise<Config> {
|
async getConfig(): Promise<Config> {
|
||||||
const response = await this.client.config.get();
|
const response = await this.client.config.get();
|
||||||
@@ -773,7 +812,7 @@ class OpencodeService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update config with a partial modification function.
|
* Update config with a partial modification function.
|
||||||
* This handles the GET-modify-PATCH pattern required by OpenCode API.
|
* This handles the GET-modify-PATCH pattern required by the upstream API.
|
||||||
*
|
*
|
||||||
* NOTE: This method is deprecated for agent configuration.
|
* NOTE: This method is deprecated for agent configuration.
|
||||||
* Use backend endpoints at /api/config/agents/* instead, which write directly to files.
|
* Use backend endpoints at /api/config/agents/* instead, which write directly to files.
|
||||||
@@ -830,6 +869,286 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseSseBlock(block: string): { data: unknown; id?: string } | null {
|
||||||
|
if (!block) return null;
|
||||||
|
|
||||||
|
const lines = block.split('\n');
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
let eventId: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).replace(/^\s/, ''));
|
||||||
|
} else if (line.startsWith('id:')) {
|
||||||
|
const candidate = line.slice(3).trim();
|
||||||
|
if (candidate) {
|
||||||
|
eventId = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadText = dataLines.join('\n').trim();
|
||||||
|
if (!payloadText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(payloadText) as unknown;
|
||||||
|
return { data, id: eventId };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRoutedSsePayload(raw: unknown): RoutedOpencodeEvent | null {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
const directoryCandidate =
|
||||||
|
typeof record.directory === 'string'
|
||||||
|
? record.directory
|
||||||
|
: typeof record.properties === 'object' && record.properties !== null
|
||||||
|
? ((record.properties as Record<string, unknown>).directory as unknown)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const normalizedDirectory =
|
||||||
|
typeof directoryCandidate === 'string'
|
||||||
|
? this.normalizeCandidatePath(directoryCandidate) ?? directoryCandidate.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (typeof record.type === 'string') {
|
||||||
|
return {
|
||||||
|
directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global',
|
||||||
|
payload: record as Event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedPayload = record.payload;
|
||||||
|
if (nestedPayload && typeof nestedPayload === 'object') {
|
||||||
|
const nestedRecord = nestedPayload as Record<string, unknown>;
|
||||||
|
if (typeof nestedRecord.type === 'string') {
|
||||||
|
return {
|
||||||
|
directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global',
|
||||||
|
payload: nestedRecord as Event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitGlobalSseEvent(event: RoutedOpencodeEvent) {
|
||||||
|
for (const listener of this.globalSseListeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[OpencodeClient] Global SSE listener error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyGlobalSseOpen() {
|
||||||
|
for (const handler of this.globalSseOpenListeners) {
|
||||||
|
try {
|
||||||
|
handler();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[OpencodeClient] Global SSE open handler error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyGlobalSseError(error: unknown) {
|
||||||
|
for (const handler of this.globalSseErrorListeners) {
|
||||||
|
try {
|
||||||
|
handler(error);
|
||||||
|
} catch (listenerError) {
|
||||||
|
console.warn('[OpencodeClient] Global SSE error handler failed:', listenerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureGlobalSseStarted() {
|
||||||
|
if (this.globalSseTask) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this.globalSseAbortController = abortController;
|
||||||
|
|
||||||
|
this.globalSseTask = this.runGlobalSseLoop(abortController)
|
||||||
|
.catch((error) => {
|
||||||
|
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[OpencodeClient] Global SSE task failed:', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.globalSseAbortController === abortController) {
|
||||||
|
this.globalSseAbortController = null;
|
||||||
|
}
|
||||||
|
this.globalSseTask = null;
|
||||||
|
this.globalSseIsConnected = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private maybeStopGlobalSse() {
|
||||||
|
if (this.globalSseListeners.size > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.globalSseAbortController && !this.globalSseAbortController.signal.aborted) {
|
||||||
|
this.globalSseAbortController.abort();
|
||||||
|
}
|
||||||
|
this.globalSseAbortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runGlobalSseLoop(abortController: AbortController): Promise<void> {
|
||||||
|
const globalEndpoint = `${this.baseUrl.replace(/\/+$/, '')}/global/event`;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
while (!abortController.signal.aborted) {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
};
|
||||||
|
if (this.globalSseLastEventId) {
|
||||||
|
headers['Last-Event-ID'] = this.globalSseLastEventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(globalEndpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Global SSE connect failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt = 0;
|
||||||
|
this.globalSseIsConnected = true;
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
this.notifyGlobalSseOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (abortController.signal.aborted) break;
|
||||||
|
if (!value || value.length === 0) continue;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||||
|
const blocks = buffer.split('\n\n');
|
||||||
|
buffer = blocks.pop() ?? '';
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const parsed = this.parseSseBlock(block);
|
||||||
|
if (!parsed) continue;
|
||||||
|
if (parsed.id) {
|
||||||
|
this.globalSseLastEventId = parsed.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routed = this.normalizeRoutedSsePayload(parsed.data);
|
||||||
|
if (routed) {
|
||||||
|
this.emitGlobalSseEvent(routed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = buffer.trim();
|
||||||
|
if (remaining && !abortController.signal.aborted) {
|
||||||
|
const parsed = this.parseSseBlock(remaining);
|
||||||
|
if (parsed?.id) {
|
||||||
|
this.globalSseLastEventId = parsed.id;
|
||||||
|
}
|
||||||
|
const routed = parsed ? this.normalizeRoutedSsePayload(parsed.data) : null;
|
||||||
|
if (routed) {
|
||||||
|
this.emitGlobalSseEvent(routed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream ended; force reconnect.
|
||||||
|
this.globalSseIsConnected = false;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.globalSseIsConnected = false;
|
||||||
|
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[OpencodeClient] Global SSE stream error (will retry):', error);
|
||||||
|
this.notifyGlobalSseError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt += 1;
|
||||||
|
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToGlobalEvents(
|
||||||
|
onEvent: (event: RoutedOpencodeEvent) => void,
|
||||||
|
onError?: (error: unknown) => void,
|
||||||
|
onOpen?: () => void,
|
||||||
|
options?: { directory?: string | null }
|
||||||
|
): () => void {
|
||||||
|
const directoryFilter = this.normalizeCandidatePath(options?.directory ?? null);
|
||||||
|
const listener = (event: RoutedOpencodeEvent) => {
|
||||||
|
if (directoryFilter && event.directory !== directoryFilter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.globalSseListeners.add(listener);
|
||||||
|
|
||||||
|
if (onOpen) {
|
||||||
|
this.globalSseOpenListeners.add(onOpen);
|
||||||
|
if (this.globalSseIsConnected) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.globalSseOpenListeners.has(onOpen)) {
|
||||||
|
try {
|
||||||
|
onOpen();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[OpencodeClient] Global SSE open handler error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onError) {
|
||||||
|
this.globalSseErrorListeners.add(onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ensureGlobalSseStarted();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.globalSseListeners.delete(listener);
|
||||||
|
if (onOpen) {
|
||||||
|
this.globalSseOpenListeners.delete(onOpen);
|
||||||
|
}
|
||||||
|
if (onError) {
|
||||||
|
this.globalSseErrorListeners.delete(onError);
|
||||||
|
}
|
||||||
|
this.maybeStopGlobalSse();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Event Streaming using SDK SSE (Server-Sent Events) with AsyncGenerator
|
// Event Streaming using SDK SSE (Server-Sent Events) with AsyncGenerator
|
||||||
subscribeToEvents(
|
subscribeToEvents(
|
||||||
onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void,
|
onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void,
|
||||||
@@ -839,6 +1158,7 @@ class OpencodeService {
|
|||||||
options?: { scope?: 'global' | 'directory'; key?: string }
|
options?: { scope?: 'global' | 'directory'; key?: string }
|
||||||
): () => void {
|
): () => void {
|
||||||
const subscriptionKey = options?.key ?? 'default';
|
const subscriptionKey = options?.key ?? 'default';
|
||||||
|
const scope = options?.scope ?? 'directory';
|
||||||
const existingController = this.sseAbortControllers.get(subscriptionKey);
|
const existingController = this.sseAbortControllers.get(subscriptionKey);
|
||||||
if (existingController) {
|
if (existingController) {
|
||||||
existingController.abort();
|
existingController.abort();
|
||||||
@@ -850,17 +1170,122 @@ class OpencodeService {
|
|||||||
|
|
||||||
let lastEventId: string | undefined;
|
let lastEventId: string | undefined;
|
||||||
|
|
||||||
|
if (scope === 'global') {
|
||||||
|
let globalUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
|
const attachDirectory = (event: RoutedOpencodeEvent): Event => {
|
||||||
|
if (event.directory === 'global') {
|
||||||
|
return event.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadRecord = event.payload as unknown as Record<string, unknown>;
|
||||||
|
const existingProperties =
|
||||||
|
typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null
|
||||||
|
? (payloadRecord.properties as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (existingProperties.directory === event.directory) {
|
||||||
|
return event.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payloadRecord,
|
||||||
|
properties: {
|
||||||
|
...existingProperties,
|
||||||
|
directory: event.directory,
|
||||||
|
},
|
||||||
|
} as Event;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (globalUnsub) {
|
||||||
|
try {
|
||||||
|
globalUnsub();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
globalUnsub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
|
||||||
|
this.sseAbortControllers.delete(subscriptionKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
abortController.signal.addEventListener('abort', cleanup, { once: true });
|
||||||
|
|
||||||
|
globalUnsub = this.subscribeToGlobalEvents(
|
||||||
|
(event) => {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMessage(attachDirectory(event));
|
||||||
|
},
|
||||||
|
onError
|
||||||
|
? (error) => {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
onError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onOpen
|
||||||
|
? () => {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanup();
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeEventPayload = (payload: unknown): Event | null => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (typeof record.type === 'string') {
|
||||||
|
return record as Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedPayload = record.payload;
|
||||||
|
if (nestedPayload && typeof nestedPayload === 'object') {
|
||||||
|
const nestedRecord = nestedPayload as Record<string, unknown>;
|
||||||
|
if (typeof nestedRecord.type === 'string') {
|
||||||
|
if (typeof record.directory === 'string' && record.directory.length > 0) {
|
||||||
|
const existingProperties =
|
||||||
|
typeof nestedRecord.properties === 'object' && nestedRecord.properties !== null
|
||||||
|
? (nestedRecord.properties as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const properties = {
|
||||||
|
...(existingProperties ?? {}),
|
||||||
|
directory: record.directory,
|
||||||
|
};
|
||||||
|
return { ...nestedRecord, properties } as Event;
|
||||||
|
}
|
||||||
|
return nestedRecord as Event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
console.log('[OpencodeClient] Starting SSE subscription...');
|
console.log('[OpencodeClient] Starting SSE subscription...');
|
||||||
|
|
||||||
// Start async generator in background with reconnect on failure
|
// Start async generator in background with reconnect on failure
|
||||||
(async () => {
|
(async () => {
|
||||||
const resolvedDirectory =
|
const resolvedDirectory =
|
||||||
options?.scope === 'global'
|
typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
|
||||||
? undefined
|
? directoryOverride.trim()
|
||||||
: typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
|
: this.currentDirectory;
|
||||||
? directoryOverride.trim()
|
|
||||||
: this.currentDirectory;
|
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'default');
|
||||||
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'global');
|
|
||||||
|
|
||||||
const connect = async (attempt: number): Promise<void> => {
|
const connect = async (attempt: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -891,8 +1316,9 @@ class OpencodeService {
|
|||||||
lastEventId = event.id;
|
lastEventId = event.id;
|
||||||
}
|
}
|
||||||
const payload = event.data;
|
const payload = event.data;
|
||||||
if (payload && typeof payload === 'object') {
|
const normalized = normalizeEventPayload(payload);
|
||||||
onMessage(payload as Event);
|
if (normalized) {
|
||||||
|
onMessage(normalized);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -915,13 +1341,6 @@ class OpencodeService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!abortController.signal.aborted) {
|
|
||||||
// Stream ended unexpectedly; attempt reconnect
|
|
||||||
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
await connect(attempt + 1);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
|
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
|
||||||
console.log('[OpencodeClient] SSE stream aborted normally');
|
console.log('[OpencodeClient] SSE stream aborted normally');
|
||||||
@@ -936,6 +1355,13 @@ class OpencodeService {
|
|||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
await connect(attempt + 1);
|
await connect(attempt + 1);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
await connect(attempt + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -956,6 +1382,7 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// File Operations
|
// File Operations
|
||||||
@@ -1092,7 +1519,7 @@ class OpencodeService {
|
|||||||
|
|
||||||
const healthData = await response.json();
|
const healthData = await response.json();
|
||||||
|
|
||||||
// Check if OpenCode is actually ready (not just OpenChamber server)
|
// Check if the upstream API is ready (not just OpenChamber server)
|
||||||
if (healthData.isOpenCodeReady === false) {
|
if (healthData.isOpenCodeReady === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
|||||||
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
||||||
window.__OPENCHAMBER_HOME__ = settings.homeDirectory;
|
window.__OPENCHAMBER_HOME__ = settings.homeDirectory;
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
|
||||||
|
localStorage.setItem('projects', JSON.stringify(settings.projects));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('projects');
|
||||||
|
}
|
||||||
|
if (settings.activeProjectId) {
|
||||||
|
localStorage.setItem('activeProjectId', settings.activeProjectId);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('activeProjectId');
|
||||||
|
}
|
||||||
if (Array.isArray(settings.pinnedDirectories) && settings.pinnedDirectories.length > 0) {
|
if (Array.isArray(settings.pinnedDirectories) && settings.pinnedDirectories.length > 0) {
|
||||||
localStorage.setItem('pinnedDirectories', JSON.stringify(settings.pinnedDirectories));
|
localStorage.setItem('pinnedDirectories', JSON.stringify(settings.pinnedDirectories));
|
||||||
} else {
|
} else {
|
||||||
@@ -78,6 +88,55 @@ const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs']
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefined => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: NonNullable<DesktopSettings['projects']> = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seenPaths = new Set<string>();
|
||||||
|
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const candidate = entry as Record<string, unknown>;
|
||||||
|
|
||||||
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||||
|
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
||||||
|
if (!id || !rawPath) continue;
|
||||||
|
|
||||||
|
const normalizedPath = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
|
if (!normalizedPath) continue;
|
||||||
|
|
||||||
|
if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue;
|
||||||
|
seenIds.add(id);
|
||||||
|
seenPaths.add(normalizedPath);
|
||||||
|
|
||||||
|
const project: NonNullable<DesktopSettings['projects']>[number] = {
|
||||||
|
id,
|
||||||
|
path: normalizedPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) {
|
||||||
|
project.label = candidate.label.trim();
|
||||||
|
}
|
||||||
|
if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) {
|
||||||
|
project.addedAt = candidate.addedAt;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof candidate.lastOpenedAt === 'number' &&
|
||||||
|
Number.isFinite(candidate.lastOpenedAt) &&
|
||||||
|
candidate.lastOpenedAt >= 0
|
||||||
|
) {
|
||||||
|
project.lastOpenedAt = candidate.lastOpenedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.length > 0 ? result : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const getPersistApi = (): PersistApi | undefined => {
|
const getPersistApi = (): PersistApi | undefined => {
|
||||||
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
|
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
|
||||||
if (candidate && typeof candidate === 'object') {
|
if (candidate && typeof candidate === 'object') {
|
||||||
@@ -138,6 +197,15 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
||||||
result.homeDirectory = candidate.homeDirectory;
|
result.homeDirectory = candidate.homeDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projects = sanitizeProjects(candidate.projects);
|
||||||
|
if (projects) {
|
||||||
|
result.projects = projects;
|
||||||
|
}
|
||||||
|
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
|
||||||
|
result.activeProjectId = candidate.activeProjectId;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(candidate.approvedDirectories)) {
|
if (Array.isArray(candidate.approvedDirectories)) {
|
||||||
result.approvedDirectories = candidate.approvedDirectories.filter(
|
result.approvedDirectories = candidate.approvedDirectories.filter(
|
||||||
(entry): entry is string => typeof entry === 'string' && entry.length > 0
|
(entry): entry is string => typeof entry === 'string' && entry.length > 0
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type SessionDeleteRequest = {
|
|||||||
export type SessionCreateRequest = {
|
export type SessionCreateRequest = {
|
||||||
worktreeMode?: 'main' | 'create' | 'reuse';
|
worktreeMode?: 'main' | 'create' | 'reuse';
|
||||||
parentID?: string | null;
|
parentID?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeleteListener = (request: SessionDeleteRequest) => void;
|
type DeleteListener = (request: SessionDeleteRequest) => void;
|
||||||
|
|||||||
@@ -280,16 +280,8 @@ const resolveSessionDirectory = async (sessionId: string | null | undefined): Pr
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionStore = useSessionStore.getState();
|
const sessionStore = useSessionStore.getState();
|
||||||
const metadata = sessionStore.getWorktreeMetadata(sessionId);
|
const directory = sessionStore.getDirectoryForSession(sessionId);
|
||||||
if (metadata?.path) {
|
return directory ?? undefined;
|
||||||
return metadata.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { directory?: string } | undefined;
|
|
||||||
const sessionDirectory =
|
|
||||||
typeof session?.directory === 'string' && session.directory.length > 0 ? session.directory : undefined;
|
|
||||||
|
|
||||||
return sessionDirectory;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to resolve session directory override:', error);
|
console.warn('Failed to resolve session directory override:', error);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
||||||
import { opencodeClient } from "@/lib/opencode/client";
|
import { opencodeClient } from "@/lib/opencode/client";
|
||||||
import type { Permission, PermissionResponse } from "@/types/permission";
|
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
|
||||||
import { isEditPermissionType, getAgentDefaultEditPermission } from "./utils/permissionUtils";
|
import { isEditPermissionType, getAgentDefaultEditPermission } from "./utils/permissionUtils";
|
||||||
import { getSafeStorage } from "./utils/safeStorage";
|
import { getSafeStorage } from "./utils/safeStorage";
|
||||||
import { useMessageStore } from "./messageStore";
|
import { useMessageStore } from "./messageStore";
|
||||||
import { useSessionStore } from "./sessionStore";
|
import { useSessionStore } from "./sessionStore";
|
||||||
|
|
||||||
interface PermissionState {
|
interface PermissionState {
|
||||||
permissions: Map<string, Permission[]>;
|
permissions: Map<string, PermissionRequest[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PermissionActions {
|
interface PermissionActions {
|
||||||
addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void;
|
addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void;
|
||||||
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise<void>;
|
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PermissionStore = PermissionState & PermissionActions;
|
type PermissionStore = PermissionState & PermissionActions;
|
||||||
@@ -21,11 +21,11 @@ type PermissionStore = PermissionState & PermissionActions;
|
|||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null;
|
typeof value === "object" && value !== null;
|
||||||
|
|
||||||
const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]> => {
|
const sanitizePermissionEntries = (value: unknown): Array<[string, PermissionRequest[]]> => {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const entries: Array<[string, Permission[]]> = [];
|
const entries: Array<[string, PermissionRequest[]]> = [];
|
||||||
value.forEach((entry) => {
|
value.forEach((entry) => {
|
||||||
if (!Array.isArray(entry) || entry.length !== 2) {
|
if (!Array.isArray(entry) || entry.length !== 2) {
|
||||||
return;
|
return;
|
||||||
@@ -34,7 +34,7 @@ const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]
|
|||||||
if (typeof sessionId !== "string" || !Array.isArray(permissions)) {
|
if (typeof sessionId !== "string" || !Array.isArray(permissions)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
entries.push([sessionId, permissions as Permission[]]);
|
entries.push([sessionId, permissions as PermissionRequest[]]);
|
||||||
});
|
});
|
||||||
return entries;
|
return entries;
|
||||||
};
|
};
|
||||||
@@ -42,15 +42,7 @@ const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]
|
|||||||
const executeWithPermissionDirectory = async <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => {
|
const executeWithPermissionDirectory = async <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
const sessionStore = useSessionStore.getState();
|
const sessionStore = useSessionStore.getState();
|
||||||
const metadata = sessionStore.getWorktreeMetadata(sessionId);
|
const directory = sessionStore.getDirectoryForSession(sessionId);
|
||||||
if (metadata?.path) {
|
|
||||||
return opencodeClient.withDirectory(metadata.path, operation);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { directory?: string } | undefined;
|
|
||||||
const directory =
|
|
||||||
typeof session?.directory === 'string' && session.directory.length > 0 ? session.directory : undefined;
|
|
||||||
|
|
||||||
if (directory) {
|
if (directory) {
|
||||||
return opencodeClient.withDirectory(directory, operation);
|
return opencodeClient.withDirectory(directory, operation);
|
||||||
}
|
}
|
||||||
@@ -67,13 +59,18 @@ export const usePermissionStore = create<PermissionStore>()(
|
|||||||
|
|
||||||
permissions: new Map(),
|
permissions: new Map(),
|
||||||
|
|
||||||
addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => {
|
addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => {
|
||||||
const sessionId = permission.sessionID;
|
const sessionId = permission.sessionID;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissionType = permission.type?.toLowerCase?.() ?? null;
|
const existing = get().permissions.get(sessionId);
|
||||||
|
if (existing?.some((entry) => entry.id === permission.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionType = permission.permission?.toLowerCase?.() ?? null;
|
||||||
|
|
||||||
let agentName = contextData?.currentAgentContext?.get(sessionId);
|
let agentName = contextData?.currentAgentContext?.get(sessionId);
|
||||||
if (!agentName) {
|
if (!agentName) {
|
||||||
@@ -109,8 +106,8 @@ export const usePermissionStore = create<PermissionStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
respondToPermission: async (sessionId: string, permissionId: string, response: PermissionResponse) => {
|
respondToPermission: async (sessionId: string, requestId: string, response: PermissionResponse) => {
|
||||||
await executeWithPermissionDirectory(sessionId, () => opencodeClient.respondToPermission(sessionId, permissionId, response));
|
await executeWithPermissionDirectory(sessionId, () => opencodeClient.replyToPermission(requestId, response));
|
||||||
|
|
||||||
if (response === 'reject') {
|
if (response === 'reject') {
|
||||||
const messageStore = useMessageStore.getState();
|
const messageStore = useMessageStore.getState();
|
||||||
@@ -120,7 +117,7 @@ export const usePermissionStore = create<PermissionStore>()(
|
|||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const sessionPermissions = state.permissions.get(sessionId) || [];
|
const sessionPermissions = state.permissions.get(sessionId) || [];
|
||||||
const updatedPermissions = sessionPermissions.filter((p) => p.id !== permissionId);
|
const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId);
|
||||||
const newPermissions = new Map(state.permissions);
|
const newPermissions = new Map(state.permissions);
|
||||||
newPermissions.set(sessionId, updatedPermissions);
|
newPermissions.set(sessionId, updatedPermissions);
|
||||||
return { permissions: newPermissions };
|
return { permissions: newPermissions };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
|
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
|
||||||
import type { Permission, PermissionResponse } from "@/types/permission";
|
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
|
||||||
|
|
||||||
export interface AttachedFile {
|
export interface AttachedFile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -70,13 +70,14 @@ export type NewSessionDraftState = {
|
|||||||
export interface SessionStore {
|
export interface SessionStore {
|
||||||
|
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
|
sessionsByDirectory: Map<string, Session[]>;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
lastLoadedDirectory: string | null;
|
lastLoadedDirectory: string | null;
|
||||||
messages: Map<string, { info: Message; parts: Part[] }[]>;
|
messages: Map<string, { info: Message; parts: Part[] }[]>;
|
||||||
sessionMemoryState: Map<string, SessionMemoryState>;
|
sessionMemoryState: Map<string, SessionMemoryState>;
|
||||||
messageStreamStates: Map<string, MessageStreamLifecycle>;
|
messageStreamStates: Map<string, MessageStreamLifecycle>;
|
||||||
sessionCompactionUntil: Map<string, number>;
|
sessionCompactionUntil: Map<string, number>;
|
||||||
permissions: Map<string, Permission[]>;
|
permissions: Map<string, PermissionRequest[]>;
|
||||||
sessionAbortFlags: Map<string, { timestamp: number; acknowledged: boolean }>;
|
sessionAbortFlags: Map<string, { timestamp: number; acknowledged: boolean }>;
|
||||||
attachedFiles: AttachedFile[];
|
attachedFiles: AttachedFile[];
|
||||||
abortPromptSessionId: string | null;
|
abortPromptSessionId: string | null;
|
||||||
@@ -96,6 +97,7 @@ export interface SessionStore {
|
|||||||
webUICreatedSessions: Set<string>;
|
webUICreatedSessions: Set<string>;
|
||||||
worktreeMetadata: Map<string, import('@/types/worktree').WorktreeMetadata>;
|
worktreeMetadata: Map<string, import('@/types/worktree').WorktreeMetadata>;
|
||||||
availableWorktrees: import('@/types/worktree').WorktreeMetadata[];
|
availableWorktrees: import('@/types/worktree').WorktreeMetadata[];
|
||||||
|
availableWorktreesByProject: Map<string, import('@/types/worktree').WorktreeMetadata[]>;
|
||||||
|
|
||||||
currentAgentContext: Map<string, string>;
|
currentAgentContext: Map<string, string>;
|
||||||
|
|
||||||
@@ -139,10 +141,11 @@ export interface SessionStore {
|
|||||||
markMessageStreamSettled: (messageId: string) => void;
|
markMessageStreamSettled: (messageId: string) => void;
|
||||||
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Message) => void;
|
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Message) => void;
|
||||||
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void;
|
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void;
|
||||||
addPermission: (permission: Permission) => void;
|
addPermission: (permission: PermissionRequest) => void;
|
||||||
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise<void>;
|
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
getSessionsByDirectory: (directory: string) => Session[];
|
getSessionsByDirectory: (directory: string) => Session[];
|
||||||
|
getDirectoryForSession: (sessionId: string) => string | null;
|
||||||
getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null;
|
getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null;
|
||||||
getCurrentAgent: (sessionId: string) => string | undefined;
|
getCurrentAgent: (sessionId: string) => string | undefined;
|
||||||
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void;
|
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void;
|
||||||
@@ -190,6 +193,7 @@ export interface SessionStore {
|
|||||||
|
|
||||||
pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => void;
|
pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => void;
|
||||||
updateSession: (session: Session) => void;
|
updateSession: (session: Session) => void;
|
||||||
|
removeSessionFromStore: (sessionId: string) => void;
|
||||||
|
|
||||||
revertToMessage: (sessionId: string, messageId: string) => Promise<void>;
|
revertToMessage: (sessionId: string, messageId: string) => Promise<void>;
|
||||||
handleSlashUndo: (sessionId: string) => Promise<void>;
|
handleSlashUndo: (sessionId: string) => Promise<void>;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useDirectoryStore } from './useDirectoryStore';
|
import { useDirectoryStore } from './useDirectoryStore';
|
||||||
|
import { useProjectsStore } from './useProjectsStore';
|
||||||
import { useSessionStore } from './useSessionStore';
|
import { useSessionStore } from './useSessionStore';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
|
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
|
||||||
@@ -9,6 +10,28 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
|||||||
|
|
||||||
const OPENCHAMBER_DIR = '.openchamber';
|
const OPENCHAMBER_DIR = '.openchamber';
|
||||||
|
|
||||||
|
const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => {
|
||||||
|
const projectsState = useProjectsStore.getState();
|
||||||
|
const activeProjectId = projectsState.activeProjectId;
|
||||||
|
const activeProjectPath = activeProjectId
|
||||||
|
? projectsState.projects.find((project) => project.id === activeProjectId)?.path
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) {
|
||||||
|
return activeProjectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCurrent = typeof currentDirectory === 'string' ? normalize(currentDirectory) : '';
|
||||||
|
const marker = `/${OPENCHAMBER_DIR}/`;
|
||||||
|
const markerIndex = normalizedCurrent.indexOf(marker);
|
||||||
|
|
||||||
|
if (markerIndex > 0) {
|
||||||
|
return normalizedCurrent.slice(0, markerIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentDirectory ? normalize(currentDirectory) : null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent group session parsed from OpenCode session titles.
|
* Agent group session parsed from OpenCode session titles.
|
||||||
* Session titles follow pattern: `groupSlug/provider/model` or `groupSlug/provider/model/index`
|
* Session titles follow pattern: `groupSlug/provider/model` or `groupSlug/provider/model/index`
|
||||||
@@ -69,12 +92,16 @@ interface AgentGroupsActions {
|
|||||||
selectGroup: (groupName: string | null) => void;
|
selectGroup: (groupName: string | null) => void;
|
||||||
/** Select a session within the current group */
|
/** Select a session within the current group */
|
||||||
selectSession: (sessionId: string | null) => void;
|
selectSession: (sessionId: string | null) => void;
|
||||||
|
/** Delete the entire group (all worktrees + sessions in those worktrees). */
|
||||||
|
deleteGroup: (groupName: string) => Promise<boolean>;
|
||||||
|
/** Delete a single worktree within a group (and all sessions in that worktree). */
|
||||||
|
deleteGroupWorktree: (groupName: string, worktreePath: string) => Promise<boolean>;
|
||||||
|
/** Keep one worktree and remove all others in the group. */
|
||||||
|
keepOnlyGroupWorktree: (groupName: string, keepWorktreePath: string) => Promise<boolean>;
|
||||||
/** Get the currently selected group */
|
/** Get the currently selected group */
|
||||||
getSelectedGroup: () => AgentGroup | null;
|
getSelectedGroup: () => AgentGroup | null;
|
||||||
/** Get the currently selected session */
|
/** Get the currently selected session */
|
||||||
getSelectedSession: () => AgentGroupSession | null;
|
getSelectedSession: () => AgentGroupSession | null;
|
||||||
/** Delete a group and all its sessions, archiving worktrees */
|
|
||||||
deleteGroup: (groupName: string) => Promise<{ success: boolean; deletedCount: number; failedCount: number }>;
|
|
||||||
/** Clear error */
|
/** Clear error */
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
@@ -88,6 +115,214 @@ const normalize = (value: string): string => {
|
|||||||
return replaced.replace(/\/+$/, '');
|
return replaced.replace(/\/+$/, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildOpenChamberRoot = (projectDirectory: string): string => {
|
||||||
|
const normalizedProject = normalize(projectDirectory);
|
||||||
|
if (!normalizedProject || normalizedProject === '/') {
|
||||||
|
return `/${OPENCHAMBER_DIR}`;
|
||||||
|
}
|
||||||
|
return `${normalizedProject}/${OPENCHAMBER_DIR}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDirectoryListingPaths = (root: string, entries: Array<{ name?: string; path?: string }>): string[] => {
|
||||||
|
const normalizedRoot = normalize(root);
|
||||||
|
return entries
|
||||||
|
.map((entry) => {
|
||||||
|
const entryPath = typeof entry.path === 'string' && entry.path.trim().length > 0 ? entry.path : null;
|
||||||
|
if (entryPath) {
|
||||||
|
const normalizedEntry = normalize(entryPath);
|
||||||
|
if (normalizedEntry) {
|
||||||
|
return normalizedEntry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
|
||||||
|
if (!name || !normalizedRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${normalizedRoot}/${name}`;
|
||||||
|
})
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const listOpenChamberDirectories = async (root: string): Promise<string[]> => {
|
||||||
|
const normalizedRoot = normalize(root);
|
||||||
|
if (!normalizedRoot) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await opencodeClient.listLocalDirectory(normalizedRoot);
|
||||||
|
const directories = entries.filter((entry) => entry.isDirectory);
|
||||||
|
return resolveDirectoryListingPaths(normalizedRoot, directories);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startsWithDirectory = (candidate: string, root: string): boolean => {
|
||||||
|
const normalizedCandidate = normalize(candidate);
|
||||||
|
const normalizedRoot = normalize(root);
|
||||||
|
if (!normalizedCandidate || !normalizedRoot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizedCandidate === normalizedRoot) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const prefix = normalizedRoot === '/' ? '/' : `${normalizedRoot}/`;
|
||||||
|
return normalizedCandidate.startsWith(prefix);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCanonicalDirectory = async (
|
||||||
|
apiClient: ReturnType<typeof opencodeClient.getApiClient>,
|
||||||
|
directory: string
|
||||||
|
): Promise<string> => {
|
||||||
|
const normalized = normalize(directory);
|
||||||
|
if (!normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiClient.path.get({ directory: normalized });
|
||||||
|
const canonical = normalize((response.data as { directory?: string | null } | null)?.directory ?? '');
|
||||||
|
return canonical || normalized;
|
||||||
|
} catch {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listSessionsForDirectory = async (
|
||||||
|
apiClient: ReturnType<typeof opencodeClient.getApiClient>,
|
||||||
|
directory: string
|
||||||
|
): Promise<Session[]> => {
|
||||||
|
const normalized = normalize(directory);
|
||||||
|
if (!normalized) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = await resolveCanonicalDirectory(apiClient, normalized);
|
||||||
|
|
||||||
|
const filterToDirectory = (sessions: Session[]) => {
|
||||||
|
return sessions.filter((session) => {
|
||||||
|
const dir = normalize((session as { directory?: string | null }).directory ?? '');
|
||||||
|
if (!dir) return false;
|
||||||
|
return startsWithDirectory(dir, normalized) || (canonical !== normalized && startsWithDirectory(dir, canonical));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptList = async (dir: string) => {
|
||||||
|
const response = await apiClient.session.list({ directory: dir });
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const list = filterToDirectory(await attemptList(normalized));
|
||||||
|
if (list.length > 0) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canonical && canonical !== normalized) {
|
||||||
|
try {
|
||||||
|
const list = filterToDirectory(await attemptList(canonical));
|
||||||
|
if (list.length > 0) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const global = await apiClient.session.list(undefined);
|
||||||
|
const list = Array.isArray(global.data) ? global.data : [];
|
||||||
|
return filterToDirectory(list);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWorktreeMetadataByPath = async (group: AgentGroup, projectDirectory: string): Promise<Map<string, WorktreeMetadata>> => {
|
||||||
|
const map = new Map<string, WorktreeMetadata>();
|
||||||
|
|
||||||
|
group.sessions.forEach((session) => {
|
||||||
|
if (session.worktreeMetadata) {
|
||||||
|
map.set(normalize(session.path), session.worktreeMetadata);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const missingPaths = Array.from(new Set(group.sessions.map((session) => normalize(session.path))))
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((path) => !map.has(path));
|
||||||
|
|
||||||
|
if (missingPaths.length === 0) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const infos = await listWorktrees(projectDirectory);
|
||||||
|
const infoByPath = new Map(infos.map((info) => [normalize(info.worktree), info]));
|
||||||
|
missingPaths.forEach((path) => {
|
||||||
|
const info = infoByPath.get(path);
|
||||||
|
if (info) {
|
||||||
|
map.set(path, mapWorktreeToMetadata(projectDirectory, info));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectDeleteCandidates = async (params: {
|
||||||
|
apiClient: ReturnType<typeof opencodeClient.getApiClient>;
|
||||||
|
group: AgentGroup;
|
||||||
|
projectDirectory: string;
|
||||||
|
worktreePaths: string[];
|
||||||
|
}): Promise<Array<{ worktreePath: string; sessionIds: string[]; metadata?: WorktreeMetadata }>> => {
|
||||||
|
const { apiClient, group, projectDirectory, worktreePaths } = params;
|
||||||
|
const metadataByPath = await buildWorktreeMetadataByPath(group, projectDirectory);
|
||||||
|
const sessionStore = useSessionStore.getState();
|
||||||
|
|
||||||
|
const uniqueWorktreePaths = Array.from(new Set(worktreePaths.map((path) => normalize(path)).filter(Boolean)));
|
||||||
|
const concurrency = 5;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const results: Array<{ worktreePath: string; sessionIds: string[]; metadata?: WorktreeMetadata }> = [];
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (index < uniqueWorktreePaths.length) {
|
||||||
|
const current = uniqueWorktreePaths[index];
|
||||||
|
index += 1;
|
||||||
|
|
||||||
|
const sessionsInGroup = group.sessions.filter((session) => normalize(session.path) === current).map((session) => session.id);
|
||||||
|
const cached = sessionStore.getSessionsByDirectory(current);
|
||||||
|
const cachedIds = Array.isArray(cached) ? cached.map((session) => session.id) : [];
|
||||||
|
|
||||||
|
// Prefer the session store cache (already directory-partitioned). If empty, fall back to direct API listing.
|
||||||
|
let listedIds: string[] = [];
|
||||||
|
if (cachedIds.length === 0) {
|
||||||
|
try {
|
||||||
|
const listed = await listSessionsForDirectory(apiClient, current);
|
||||||
|
listedIds = listed.map((session) => session.id);
|
||||||
|
} catch {
|
||||||
|
listedIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.from(new Set([...cachedIds, ...listedIds, ...sessionsInGroup].filter(Boolean)));
|
||||||
|
results.push({
|
||||||
|
worktreePath: current,
|
||||||
|
sessionIds: ids,
|
||||||
|
metadata: metadataByPath.get(current),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: Math.min(concurrency, uniqueWorktreePaths.length) }, worker));
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a session title to extract group, provider, model, and index.
|
* Parse a session title to extract group, provider, model, and index.
|
||||||
* Title format: groupSlug/provider/model[/index]
|
* Title format: groupSlug/provider/model[/index]
|
||||||
@@ -158,31 +393,29 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
|||||||
|
|
||||||
loadGroups: async () => {
|
loadGroups: async () => {
|
||||||
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
||||||
if (!currentDirectory) {
|
const projectDirectory = resolveProjectDirectory(currentDirectory);
|
||||||
|
|
||||||
|
if (!projectDirectory) {
|
||||||
set({ groups: [], isLoading: false, error: 'No project directory selected' });
|
set({ groups: [], isLoading: false, error: 'No project directory selected' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're inside a .openchamber worktree - if so, don't reload
|
const normalizedProject = normalize(projectDirectory);
|
||||||
// This prevents groups from disappearing when switching to a worktree session
|
const openChamberRoot = buildOpenChamberRoot(normalizedProject);
|
||||||
const normalizedCurrent = normalize(currentDirectory);
|
|
||||||
if (normalizedCurrent.includes(`/${OPENCHAMBER_DIR}/`)) {
|
|
||||||
// We're inside a worktree, don't reload groups
|
|
||||||
set({ isLoading: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousGroups = get().groups;
|
const previousGroups = get().groups;
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiClient = opencodeClient.getApiClient();
|
const apiClient = opencodeClient.getApiClient();
|
||||||
|
const canonicalProject = await resolveCanonicalDirectory(apiClient, normalizedProject);
|
||||||
|
const openChamberRootCanonical = buildOpenChamberRoot(canonicalProject);
|
||||||
|
|
||||||
// Get git worktree info first - we need to query each worktree separately
|
// Get git worktree info first - we need to query each worktree separately
|
||||||
let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>();
|
let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>();
|
||||||
let worktreeInfoList: Awaited<ReturnType<typeof listWorktrees>> = [];
|
let worktreeInfoList: Awaited<ReturnType<typeof listWorktrees>> = [];
|
||||||
try {
|
try {
|
||||||
worktreeInfoList = await listWorktrees(normalizedCurrent);
|
worktreeInfoList = await listWorktrees(normalizedProject);
|
||||||
worktreeInfoMap = new Map(
|
worktreeInfoMap = new Map(
|
||||||
worktreeInfoList.map((info) => [normalize(info.worktree), info])
|
worktreeInfoList.map((info) => [normalize(info.worktree), info])
|
||||||
);
|
);
|
||||||
@@ -190,32 +423,90 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
|||||||
console.debug('Failed to list git worktrees');
|
console.debug('Failed to list git worktrees');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch sessions from each worktree directory (sessions are stored per-directory in OpenCode)
|
const fetchCandidateSessions = async (): Promise<Session[]> => {
|
||||||
// Filter to only .openchamber worktrees (agent group worktrees)
|
try {
|
||||||
const openchamberWorktrees = worktreeInfoList.filter(
|
const scoped = await apiClient.session.list({ directory: normalizedProject });
|
||||||
(info) => normalize(info.worktree).includes(`/${OPENCHAMBER_DIR}/`)
|
const list = Array.isArray(scoped.data) ? scoped.data : [];
|
||||||
);
|
if (list.some((session) => {
|
||||||
|
const dir = normalize((session as { directory?: string | null }).directory ?? '');
|
||||||
const sessionsMap = new Map<string, Session>();
|
return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical);
|
||||||
|
})) {
|
||||||
// Fetch sessions from each openchamber worktree
|
return list;
|
||||||
await Promise.all(
|
|
||||||
openchamberWorktrees.map(async (worktree) => {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.session.list({
|
|
||||||
directory: normalize(worktree.worktree),
|
|
||||||
});
|
|
||||||
const sessions: Session[] = Array.isArray(response.data) ? response.data : [];
|
|
||||||
for (const session of sessions) {
|
|
||||||
sessionsMap.set(session.id, session);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.debug('Failed to fetch sessions from worktree:', worktree.worktree, err);
|
|
||||||
}
|
}
|
||||||
})
|
} catch {
|
||||||
);
|
// ignore and fall back to global list
|
||||||
|
}
|
||||||
const allSessions = Array.from(sessionsMap.values());
|
|
||||||
|
const global = await apiClient.session.list(undefined);
|
||||||
|
return Array.isArray(global.data) ? global.data : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSessionsByWorktreeDirectories = async (directories: string[]): Promise<Session[]> => {
|
||||||
|
const sessionsMap = new Map<string, Session>();
|
||||||
|
const concurrency = 5;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (index < directories.length) {
|
||||||
|
const current = directories[index];
|
||||||
|
index += 1;
|
||||||
|
const normalizedDir = normalize(current);
|
||||||
|
if (!normalizedDir) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await listSessionsForDirectory(apiClient, normalizedDir);
|
||||||
|
sessions.forEach((session) => sessionsMap.set(session.id, session));
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('Failed to fetch sessions from worktree:', normalizedDir, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: Math.min(concurrency, directories.length) }, worker));
|
||||||
|
return Array.from(sessionsMap.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidateSessions = await fetchCandidateSessions();
|
||||||
|
let allSessions = candidateSessions.filter((session) => {
|
||||||
|
const dir = normalize((session as { directory?: string | null }).directory ?? '');
|
||||||
|
if (!dir) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Some OpenCode builds do not return sessions across directories in the global list.
|
||||||
|
// If we didn't discover any group sessions, fall back to querying each `.openchamber` worktree directory directly.
|
||||||
|
if (allSessions.length === 0) {
|
||||||
|
const candidates = new Set<string>();
|
||||||
|
|
||||||
|
// 1) Git worktree list
|
||||||
|
worktreeInfoList
|
||||||
|
.map((info) => normalize(info.worktree))
|
||||||
|
.filter((worktreePath) =>
|
||||||
|
startsWithDirectory(worktreePath, openChamberRoot) || startsWithDirectory(worktreePath, openChamberRootCanonical)
|
||||||
|
)
|
||||||
|
.forEach((worktreePath) => candidates.add(worktreePath));
|
||||||
|
|
||||||
|
// 2) Filesystem scan (handles cases where git worktree listing breaks or isn't available)
|
||||||
|
const roots = Array.from(new Set([openChamberRoot, openChamberRootCanonical].map((p) => normalize(p)).filter(Boolean)));
|
||||||
|
await Promise.all(
|
||||||
|
roots.map(async (root) => {
|
||||||
|
const dirs = await listOpenChamberDirectories(root);
|
||||||
|
dirs.forEach((dir) => candidates.add(dir));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidates.size > 0) {
|
||||||
|
allSessions = await fetchSessionsByWorktreeDirectories(Array.from(candidates));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUpdatedAtById = new Map<string, number>();
|
||||||
|
for (const session of allSessions) {
|
||||||
|
const updatedAt = (session as { time?: { updated?: number | null } }).time?.updated ?? 0;
|
||||||
|
sessionUpdatedAtById.set(session.id, typeof updatedAt === 'number' ? updatedAt : 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse sessions and group by groupSlug
|
// Parse sessions and group by groupSlug
|
||||||
const groupsMap = new Map<string, AgentGroupSession[]>();
|
const groupsMap = new Map<string, AgentGroupSession[]>();
|
||||||
@@ -236,7 +527,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
|||||||
branch: worktreeInfo?.branch ?? '',
|
branch: worktreeInfo?.branch ?? '',
|
||||||
displayLabel: `${parsed.provider}/${parsed.model}`,
|
displayLabel: `${parsed.provider}/${parsed.model}`,
|
||||||
worktreeMetadata: worktreeInfo
|
worktreeMetadata: worktreeInfo
|
||||||
? mapWorktreeToMetadata(normalizedCurrent, worktreeInfo)
|
? mapWorktreeToMetadata(normalizedProject, worktreeInfo)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -253,9 +544,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
|||||||
([name, sessions]) => {
|
([name, sessions]) => {
|
||||||
// Find the most recent session update time for lastActive
|
// Find the most recent session update time for lastActive
|
||||||
const lastActive = sessions.reduce((max, s) => {
|
const lastActive = sessions.reduce((max, s) => {
|
||||||
// Find the original session to get the time
|
const updatedTime = sessionUpdatedAtById.get(s.id) ?? 0;
|
||||||
const originalSession = allSessions.find((os) => os.id === s.id);
|
|
||||||
const updatedTime = originalSession?.time?.updated ?? 0;
|
|
||||||
return Math.max(max, updatedTime);
|
return Math.max(max, updatedTime);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
@@ -305,6 +594,196 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
|||||||
set({ selectedSessionId: sessionId });
|
set({ selectedSessionId: sessionId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteGroup: async (groupName) => {
|
||||||
|
const group = get().groups.find((g) => g.name === groupName);
|
||||||
|
if (!group) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
||||||
|
const projectDirectory = resolveProjectDirectory(currentDirectory);
|
||||||
|
if (!projectDirectory) {
|
||||||
|
set({ error: 'No project directory selected' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const apiClient = opencodeClient.getApiClient();
|
||||||
|
const candidates = await collectDeleteCandidates({
|
||||||
|
apiClient,
|
||||||
|
group,
|
||||||
|
projectDirectory: normalize(projectDirectory),
|
||||||
|
worktreePaths: group.sessions.map((s) => s.path),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionStore = useSessionStore.getState();
|
||||||
|
const ids = new Set<string>();
|
||||||
|
candidates.forEach(({ worktreePath, sessionIds, metadata }) => {
|
||||||
|
sessionIds.forEach((id) => {
|
||||||
|
ids.add(id);
|
||||||
|
if (metadata) {
|
||||||
|
sessionStore.setWorktreeMetadata(id, metadata);
|
||||||
|
sessionStore.setSessionDirectory(id, worktreePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
|
||||||
|
if (failedIds.length > 0) {
|
||||||
|
set({ error: 'Failed to delete some sessions' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get().selectedGroupName === groupName) {
|
||||||
|
set({ selectedGroupName: null, selectedSessionId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
await get().loadGroups();
|
||||||
|
return failedIds.length === 0;
|
||||||
|
} catch (err) {
|
||||||
|
set({ error: err instanceof Error ? err.message : 'Failed to delete group' });
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteGroupWorktree: async (groupName, worktreePath) => {
|
||||||
|
const group = get().groups.find((g) => g.name === groupName);
|
||||||
|
if (!group) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalizedWorktreePath = normalize(worktreePath);
|
||||||
|
if (!normalizedWorktreePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
||||||
|
const projectDirectory = resolveProjectDirectory(currentDirectory);
|
||||||
|
if (!projectDirectory) {
|
||||||
|
set({ error: 'No project directory selected' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const apiClient = opencodeClient.getApiClient();
|
||||||
|
const candidates = await collectDeleteCandidates({
|
||||||
|
apiClient,
|
||||||
|
group,
|
||||||
|
projectDirectory: normalize(projectDirectory),
|
||||||
|
worktreePaths: [normalizedWorktreePath],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionStore = useSessionStore.getState();
|
||||||
|
const ids = new Set<string>();
|
||||||
|
candidates.forEach(({ worktreePath: resolvedPath, sessionIds, metadata }) => {
|
||||||
|
sessionIds.forEach((id) => {
|
||||||
|
ids.add(id);
|
||||||
|
if (metadata) {
|
||||||
|
sessionStore.setWorktreeMetadata(id, metadata);
|
||||||
|
sessionStore.setSessionDirectory(id, resolvedPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
|
||||||
|
if (failedIds.length > 0) {
|
||||||
|
set({ error: 'Failed to delete some sessions' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await get().loadGroups();
|
||||||
|
|
||||||
|
const updated = get().groups.find((g) => g.name === groupName);
|
||||||
|
if (!updated) {
|
||||||
|
if (get().selectedGroupName === groupName) {
|
||||||
|
set({ selectedGroupName: null, selectedSessionId: null });
|
||||||
|
}
|
||||||
|
return failedIds.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get().selectedGroupName === groupName) {
|
||||||
|
const currentSelected = get().selectedSessionId;
|
||||||
|
const remainingIds = new Set(updated.sessions.map((s) => s.id));
|
||||||
|
if (!currentSelected || !remainingIds.has(currentSelected)) {
|
||||||
|
set({ selectedSessionId: updated.sessions[0]?.id ?? null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failedIds.length === 0;
|
||||||
|
} catch (err) {
|
||||||
|
set({ error: err instanceof Error ? err.message : 'Failed to delete worktree' });
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
keepOnlyGroupWorktree: async (groupName, keepWorktreePath) => {
|
||||||
|
const group = get().groups.find((g) => g.name === groupName);
|
||||||
|
if (!group) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const keepPath = normalize(keepWorktreePath);
|
||||||
|
if (!keepPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreePaths = Array.from(new Set(group.sessions.map((s) => normalize(s.path)).filter(Boolean)));
|
||||||
|
const toDelete = worktreePaths.filter((path) => path !== keepPath);
|
||||||
|
if (toDelete.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
||||||
|
const projectDirectory = resolveProjectDirectory(currentDirectory);
|
||||||
|
if (!projectDirectory) {
|
||||||
|
set({ error: 'No project directory selected' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const apiClient = opencodeClient.getApiClient();
|
||||||
|
const candidates = await collectDeleteCandidates({
|
||||||
|
apiClient,
|
||||||
|
group,
|
||||||
|
projectDirectory: normalize(projectDirectory),
|
||||||
|
worktreePaths: toDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionStore = useSessionStore.getState();
|
||||||
|
const ids = new Set<string>();
|
||||||
|
candidates.forEach(({ worktreePath, sessionIds, metadata }) => {
|
||||||
|
sessionIds.forEach((id) => {
|
||||||
|
ids.add(id);
|
||||||
|
if (metadata) {
|
||||||
|
sessionStore.setWorktreeMetadata(id, metadata);
|
||||||
|
sessionStore.setSessionDirectory(id, worktreePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
|
||||||
|
if (failedIds.length > 0) {
|
||||||
|
set({ error: 'Failed to delete some sessions' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await get().loadGroups();
|
||||||
|
if (get().selectedGroupName === groupName) {
|
||||||
|
const updated = get().groups.find((g) => g.name === groupName);
|
||||||
|
const keepSession = updated?.sessions.find((s) => normalize(s.path) === keepPath) ?? updated?.sessions[0] ?? null;
|
||||||
|
set({ selectedSessionId: keepSession?.id ?? null });
|
||||||
|
}
|
||||||
|
return failedIds.length === 0;
|
||||||
|
} catch (err) {
|
||||||
|
set({ error: err instanceof Error ? err.message : 'Failed to remove other worktrees' });
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getSelectedGroup: () => {
|
getSelectedGroup: () => {
|
||||||
const { groups, selectedGroupName } = get();
|
const { groups, selectedGroupName } = get();
|
||||||
if (!selectedGroupName) return null;
|
if (!selectedGroupName) return null;
|
||||||
@@ -321,47 +800,6 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
|||||||
clearError: () => {
|
clearError: () => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteGroup: async (groupName: string) => {
|
|
||||||
const { groups, selectedGroupName } = get();
|
|
||||||
const group = groups.find((g) => g.name === groupName);
|
|
||||||
|
|
||||||
if (!group) {
|
|
||||||
return { success: false, deletedCount: 0, failedCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all session IDs from the group
|
|
||||||
const sessionIds = group.sessions.map((s) => s.id);
|
|
||||||
|
|
||||||
if (sessionIds.length === 0) {
|
|
||||||
return { success: true, deletedCount: 0, failedCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete sessions using sessionStore.deleteSessions
|
|
||||||
// archiveWorktree: true - removes the git worktree
|
|
||||||
// deleteRemoteBranch: false - does not delete remote branch
|
|
||||||
const { deletedIds, failedIds } = await useSessionStore.getState().deleteSessions(
|
|
||||||
sessionIds,
|
|
||||||
{
|
|
||||||
archiveWorktree: true,
|
|
||||||
deleteRemoteBranch: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the deleted group was selected, clear selection
|
|
||||||
if (selectedGroupName === groupName) {
|
|
||||||
set({ selectedGroupName: null, selectedSessionId: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload groups to reflect changes
|
|
||||||
await get().loadGroups();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: failedIds.length === 0,
|
|
||||||
deletedCount: deletedIds.length,
|
|
||||||
failedCount: failedIds.length,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
{ name: 'agent-groups-store' }
|
{ name: 'agent-groups-store' }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { StoreApi, UseBoundStore } from "zustand";
|
import type { StoreApi, UseBoundStore } from "zustand";
|
||||||
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
||||||
import type { Agent } from "@opencode-ai/sdk/v2";
|
import type { Agent, PermissionConfig } from "@opencode-ai/sdk/v2";
|
||||||
import { opencodeClient } from "@/lib/opencode/client";
|
import { opencodeClient } from "@/lib/opencode/client";
|
||||||
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
|
import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync";
|
||||||
import {
|
import {
|
||||||
startConfigUpdate,
|
startConfigUpdate,
|
||||||
finishConfigUpdate,
|
finishConfigUpdate,
|
||||||
@@ -11,13 +11,20 @@ import {
|
|||||||
} from "@/lib/configUpdate";
|
} from "@/lib/configUpdate";
|
||||||
import { getSafeStorage } from "./utils/safeStorage";
|
import { getSafeStorage } from "./utils/safeStorage";
|
||||||
import { useConfigStore } from "@/stores/useConfigStore";
|
import { useConfigStore } from "@/stores/useConfigStore";
|
||||||
|
import { useCommandsStore } from "@/stores/useCommandsStore";
|
||||||
|
import { useProjectsStore } from "@/stores/useProjectsStore";
|
||||||
|
import { useSkillsCatalogStore } from "@/stores/useSkillsCatalogStore";
|
||||||
|
import { useSkillsStore } from "@/stores/useSkillsStore";
|
||||||
|
|
||||||
// Note: useDirectoryStore cannot be imported at top level to avoid circular dependency
|
// Note: useDirectoryStore cannot be imported at top level to avoid circular dependency
|
||||||
// useDirectoryStore -> useAgentsStore (for refreshAfterOpenCodeRestart)
|
// useDirectoryStore -> useAgentsStore (for refreshAfterOpenCodeRestart)
|
||||||
// useAgentsStore -> useDirectoryStore (for currentDirectory)
|
// useAgentsStore -> useDirectoryStore (for currentDirectory)
|
||||||
// Instead we access it from the window object where it's exposed
|
|
||||||
const getCurrentDirectory = (): string | null => {
|
const getCurrentDirectory = (): string | null => {
|
||||||
// Try to get from window if store is already loaded
|
const opencodeDirectory = opencodeClient.getDirectory();
|
||||||
|
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
|
||||||
|
return opencodeDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const store = (window as any).__zustand_directory_store__;
|
const store = (window as any).__zustand_directory_store__;
|
||||||
@@ -27,6 +34,29 @@ const getCurrentDirectory = (): string | null => {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfigDirectory = (): string | null => {
|
||||||
|
try {
|
||||||
|
const projectsStore = useProjectsStore.getState();
|
||||||
|
const activeProject = projectsStore.getActiveProject?.();
|
||||||
|
|
||||||
|
// 1. Primary: Active project path from store
|
||||||
|
if (activeProject?.path?.trim()) {
|
||||||
|
return activeProject.path.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: current OpenCode directory (session / runtime)
|
||||||
|
const clientDir = opencodeClient.getDirectory();
|
||||||
|
if (clientDir?.trim()) {
|
||||||
|
return clientDir.trim();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[AgentsStore] Error resolving config directory:', err);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,15 +70,7 @@ export interface AgentConfig {
|
|||||||
top_p?: number;
|
top_p?: number;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
mode?: "primary" | "subagent" | "all";
|
mode?: "primary" | "subagent" | "all";
|
||||||
tools?: Record<string, boolean>;
|
permission?: PermissionConfig | null;
|
||||||
permission?: {
|
|
||||||
edit?: "allow" | "ask" | "deny";
|
|
||||||
bash?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
|
|
||||||
skill?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
|
|
||||||
webfetch?: "allow" | "ask" | "deny";
|
|
||||||
doom_loop?: "allow" | "ask" | "deny";
|
|
||||||
external_directory?: "allow" | "ask" | "deny";
|
|
||||||
};
|
|
||||||
|
|
||||||
disable?: boolean;
|
disable?: boolean;
|
||||||
scope?: AgentScope;
|
scope?: AgentScope;
|
||||||
@@ -96,15 +118,7 @@ export interface AgentDraft {
|
|||||||
top_p?: number;
|
top_p?: number;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
mode?: "primary" | "subagent" | "all";
|
mode?: "primary" | "subagent" | "all";
|
||||||
tools?: Record<string, boolean>;
|
permission?: PermissionConfig;
|
||||||
permission?: {
|
|
||||||
edit?: "allow" | "ask" | "deny";
|
|
||||||
bash?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
|
|
||||||
skill?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
|
|
||||||
webfetch?: "allow" | "ask" | "deny";
|
|
||||||
doom_loop?: "allow" | "ask" | "deny";
|
|
||||||
external_directory?: "allow" | "ask" | "deny";
|
|
||||||
};
|
|
||||||
disable?: boolean;
|
disable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,44 +167,67 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
loadAgents: async () => {
|
loadAgents: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
const previousAgents = get().agents;
|
const previousAgents = get().agents;
|
||||||
let lastError: unknown = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
const agents = await opencodeClient.listAgents();
|
const configDirectory = getConfigDirectory();
|
||||||
|
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||||
// Fetch scope info for each agent
|
|
||||||
const currentDirectory = getCurrentDirectory();
|
// Ensure we list agents using the correct project context
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
const agents = await opencodeClient.withDirectory(configDirectory, () => opencodeClient.listAgents());
|
||||||
|
|
||||||
const agentsWithScope = await Promise.all(
|
const agentsWithScope = await Promise.all(
|
||||||
agents.map(async (agent) => {
|
agents.map(async (agent) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`);
|
// Force no-cache to ensure we get the latest scope info
|
||||||
|
const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// Handle web/desktop response formats; fall back to JSON scope if md scope missing
|
|
||||||
const scope = data.scope ?? data.sources?.md?.scope ?? data.sources?.json?.scope;
|
// Prioritize explicit scope from server response
|
||||||
return { ...agent, scope: scope as AgentScope | undefined };
|
let scope = data.scope;
|
||||||
|
|
||||||
|
// Fallback to deducing from sources if top-level scope is missing
|
||||||
|
if (!scope && data.sources) {
|
||||||
|
const sources = data.sources;
|
||||||
|
scope = (sources.md?.exists ? sources.md.scope : undefined)
|
||||||
|
?? (sources.json?.exists ? sources.json.scope : undefined)
|
||||||
|
?? sources.md?.scope
|
||||||
|
?? sources.json?.scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'project' || scope === 'user') {
|
||||||
|
return { ...agent, scope: scope as AgentScope };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly set null scope if not found, to clear stale state
|
||||||
|
return { ...agent, scope: undefined };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Ignore scope fetch errors and fall back to agent defaults
|
console.warn(`[AgentsStore] Failed to fetch config for agent ${agent.name}:`, err);
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
set({ agents: agentsWithScope, isLoading: false });
|
if (JSON.stringify(previousAgents) !== JSON.stringify(agentsWithScope)) {
|
||||||
|
set({ agents: agentsWithScope, isLoading: false });
|
||||||
|
} else {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch {
|
||||||
lastError = error;
|
// ignore error
|
||||||
const waitMs = 200 * (attempt + 1);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Failed to load agents:", lastError);
|
set({ isLoading: false });
|
||||||
set({ agents: previousAgents, isLoading: false });
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -198,8 +235,10 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
startConfigUpdate("Creating agent configuration…");
|
startConfigUpdate("Creating agent configuration…");
|
||||||
let requiresReload = false;
|
let requiresReload = false;
|
||||||
try {
|
try {
|
||||||
|
console.log('[AgentsStore] Creating agent:', config.name);
|
||||||
|
|
||||||
const agentConfig: Record<string, unknown> = {
|
const agentConfig: Record<string, unknown> = {
|
||||||
mode: config.mode || "subagent",
|
mode: config.mode || 'subagent',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.description) agentConfig.description = config.description;
|
if (config.description) agentConfig.description = config.description;
|
||||||
@@ -207,18 +246,21 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
|
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
|
||||||
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
|
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
|
||||||
if (config.prompt) agentConfig.prompt = config.prompt;
|
if (config.prompt) agentConfig.prompt = config.prompt;
|
||||||
if (config.tools && Object.keys(config.tools).length > 0) agentConfig.tools = config.tools;
|
|
||||||
if (config.permission) agentConfig.permission = config.permission;
|
if (config.permission) agentConfig.permission = config.permission;
|
||||||
if (config.disable !== undefined) agentConfig.disable = config.disable;
|
if (config.disable !== undefined) agentConfig.disable = config.disable;
|
||||||
if (config.scope) agentConfig.scope = config.scope;
|
if (config.scope) agentConfig.scope = config.scope;
|
||||||
|
|
||||||
// Get current directory for project-level agent support
|
console.log('[AgentsStore] Agent config to save:', agentConfig);
|
||||||
const currentDirectory = getCurrentDirectory();
|
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
const configDirectory = getConfigDirectory();
|
||||||
|
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||||
|
|
||||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, {
|
const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify(agentConfig)
|
body: JSON.stringify(agentConfig)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,9 +273,11 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
const needsReload = payload?.requiresReload ?? true;
|
const needsReload = payload?.requiresReload ?? true;
|
||||||
if (needsReload) {
|
if (needsReload) {
|
||||||
requiresReload = true;
|
requiresReload = true;
|
||||||
await performFullConfigRefresh({
|
await refreshAfterOpenCodeRestart({
|
||||||
message: payload?.message,
|
message: payload?.message,
|
||||||
delayMs: payload?.reloadDelayMs,
|
delayMs: payload?.reloadDelayMs,
|
||||||
|
scopes: ["agents"],
|
||||||
|
mode: "active",
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -243,7 +287,8 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
|
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
|
||||||
}
|
}
|
||||||
return loaded;
|
return loaded;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error('Failed to create agent:', error);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (!requiresReload) {
|
if (!requiresReload) {
|
||||||
@@ -264,17 +309,19 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
|
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
|
||||||
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
|
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
|
||||||
if (config.prompt !== undefined) agentConfig.prompt = config.prompt;
|
if (config.prompt !== undefined) agentConfig.prompt = config.prompt;
|
||||||
if (config.tools !== undefined) agentConfig.tools = config.tools;
|
|
||||||
if (config.permission !== undefined) agentConfig.permission = config.permission;
|
if (config.permission !== undefined) agentConfig.permission = config.permission;
|
||||||
if (config.disable !== undefined) agentConfig.disable = config.disable;
|
if (config.disable !== undefined) agentConfig.disable = config.disable;
|
||||||
|
|
||||||
// Get current directory for project-level agent support
|
// Use active project root for project-level agent support.
|
||||||
const currentDirectory = getCurrentDirectory();
|
const configDirectory = getConfigDirectory();
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||||
|
|
||||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
|
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify(agentConfig)
|
body: JSON.stringify(agentConfig)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,9 +334,11 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
const needsReload = payload?.requiresReload ?? true;
|
const needsReload = payload?.requiresReload ?? true;
|
||||||
if (needsReload) {
|
if (needsReload) {
|
||||||
requiresReload = true;
|
requiresReload = true;
|
||||||
await performFullConfigRefresh({
|
await refreshAfterOpenCodeRestart({
|
||||||
message: payload?.message,
|
message: payload?.message,
|
||||||
delayMs: payload?.reloadDelayMs,
|
delayMs: payload?.reloadDelayMs,
|
||||||
|
scopes: ["agents"],
|
||||||
|
mode: "active",
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -299,8 +348,9 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
|
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
|
||||||
}
|
}
|
||||||
return loaded;
|
return loaded;
|
||||||
} catch {
|
} catch (error) {
|
||||||
return false;
|
console.error('Failed to update agent:', error);
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (!requiresReload) {
|
if (!requiresReload) {
|
||||||
finishConfigUpdate();
|
finishConfigUpdate();
|
||||||
@@ -312,12 +362,13 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
startConfigUpdate("Deleting agent configuration…");
|
startConfigUpdate("Deleting agent configuration…");
|
||||||
let requiresReload = false;
|
let requiresReload = false;
|
||||||
try {
|
try {
|
||||||
// Get current directory for project-level agent support
|
// Use active project root for project-level agent support.
|
||||||
const currentDirectory = getCurrentDirectory();
|
const configDirectory = getConfigDirectory();
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||||
|
|
||||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
|
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
@@ -329,9 +380,11 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
const needsReload = payload?.requiresReload ?? true;
|
const needsReload = payload?.requiresReload ?? true;
|
||||||
if (needsReload) {
|
if (needsReload) {
|
||||||
requiresReload = true;
|
requiresReload = true;
|
||||||
await performFullConfigRefresh({
|
await refreshAfterOpenCodeRestart({
|
||||||
message: payload?.message,
|
message: payload?.message,
|
||||||
delayMs: payload?.reloadDelayMs,
|
delayMs: payload?.reloadDelayMs,
|
||||||
|
scopes: ["agents"],
|
||||||
|
mode: "active",
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -344,7 +397,6 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
if (get().selectedAgentName === name) {
|
if (get().selectedAgentName === name) {
|
||||||
set({ selectedAgentName: null });
|
set({ selectedAgentName: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
return loaded;
|
return loaded;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -355,6 +407,7 @@ export const useAgentsStore = create<AgentsStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
getAgentByName: (name: string) => {
|
getAgentByName: (name: string) => {
|
||||||
const { agents } = get();
|
const { agents } = get();
|
||||||
return agents.find((a) => a.name === name);
|
return agents.find((a) => a.name === name);
|
||||||
@@ -427,45 +480,112 @@ async function waitForOpenCodeConnection(delayMs?: number) {
|
|||||||
throw lastError || new Error("OpenCode did not become ready in time");
|
throw lastError || new Error("OpenCode did not become ready in time");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performFullConfigRefresh(options: { message?: string; delayMs?: number } = {}) {
|
type ConfigRefreshMode = "active" | "projects";
|
||||||
|
|
||||||
|
const normalizeRefreshScopes = (scopes?: ConfigChangeScope[]): ConfigChangeScope[] => {
|
||||||
|
if (!scopes || scopes.length === 0) {
|
||||||
|
return ["all"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = Array.from(new Set(scopes));
|
||||||
|
if (unique.includes("all")) {
|
||||||
|
return ["all"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function performConfigRefresh(options: {
|
||||||
|
message?: string;
|
||||||
|
delayMs?: number;
|
||||||
|
scopes?: ConfigChangeScope[];
|
||||||
|
mode?: ConfigRefreshMode;
|
||||||
|
} = {}) {
|
||||||
const { message, delayMs } = options;
|
const { message, delayMs } = options;
|
||||||
|
const scopes = normalizeRefreshScopes(options.scopes);
|
||||||
|
const mode: ConfigRefreshMode = options.mode ?? (scopes.includes("all") ? "projects" : "active");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
updateConfigUpdateMessage(message || "Reloading OpenCode configuration…");
|
updateConfigUpdateMessage(message || "Refreshing configuration…");
|
||||||
if (typeof window !== "undefined" && window.localStorage) {
|
|
||||||
window.localStorage.removeItem("agents-store");
|
|
||||||
window.localStorage.removeItem("config-store");
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore local storage cleanup errors
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForOpenCodeConnection(delayMs);
|
await waitForOpenCodeConnection(delayMs);
|
||||||
updateConfigUpdateMessage("Refreshing providers and agents…");
|
|
||||||
|
|
||||||
const configStore = useConfigStore.getState();
|
const configStore = useConfigStore.getState();
|
||||||
const agentsStore = useAgentsStore.getState();
|
const agentConfigStore = useAgentsStore.getState();
|
||||||
|
const commandsStore = useCommandsStore.getState();
|
||||||
|
const skillsStore = useSkillsStore.getState();
|
||||||
|
const skillsCatalogStore = useSkillsCatalogStore.getState();
|
||||||
|
|
||||||
await Promise.all([
|
const refreshProviders = scopes.includes("all") || scopes.includes("providers");
|
||||||
configStore.loadProviders().then(() => undefined),
|
const refreshSdkAgents = scopes.includes("all") || scopes.includes("agents");
|
||||||
agentsStore.loadAgents().then(() => undefined),
|
const refreshAgentConfigs = scopes.includes("all") || scopes.includes("agents");
|
||||||
]);
|
const refreshCommands = scopes.includes("all") || scopes.includes("commands");
|
||||||
|
const refreshSkills = scopes.includes("all") || scopes.includes("skills");
|
||||||
|
|
||||||
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
|
const currentDirectory = getCurrentDirectory();
|
||||||
|
const projects = mode === "projects" ? useProjectsStore.getState().projects : [];
|
||||||
|
const directoriesToRefresh = Array.from(
|
||||||
|
new Set([
|
||||||
|
...(currentDirectory ? [currentDirectory] : []),
|
||||||
|
...projects.map((project) => project.path).filter(Boolean),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scopes.includes("all") && mode === "projects") {
|
||||||
|
useConfigStore.setState({ directoryScoped: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sdkRefreshTasks: Promise<void>[] = [];
|
||||||
|
for (const directory of directoriesToRefresh) {
|
||||||
|
if (refreshProviders) {
|
||||||
|
sdkRefreshTasks.push(configStore.loadProviders({ directory }).then(() => undefined));
|
||||||
|
}
|
||||||
|
if (refreshSdkAgents) {
|
||||||
|
sdkRefreshTasks.push(configStore.loadAgents({ directory }).then(() => undefined));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiRefreshTasks: Promise<void>[] = [];
|
||||||
|
if (refreshAgentConfigs) {
|
||||||
|
uiRefreshTasks.push(agentConfigStore.loadAgents().then(() => undefined));
|
||||||
|
}
|
||||||
|
if (refreshCommands) {
|
||||||
|
uiRefreshTasks.push(commandsStore.loadCommands().then(() => undefined));
|
||||||
|
}
|
||||||
|
if (refreshSkills) {
|
||||||
|
uiRefreshTasks.push(skillsStore.loadSkills().then(() => undefined));
|
||||||
|
uiRefreshTasks.push(skillsCatalogStore.loadCatalog().then(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigUpdateMessage("Refreshing configuration…");
|
||||||
|
await Promise.all([...sdkRefreshTasks, ...uiRefreshTasks]);
|
||||||
} catch {
|
} catch {
|
||||||
updateConfigUpdateMessage("OpenCode reload failed. Please retry refreshing configuration manually.");
|
updateConfigUpdateMessage("OpenCode refresh failed. Please retry.");
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
} finally {
|
} finally {
|
||||||
finishConfigUpdate();
|
finishConfigUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) {
|
export async function refreshAfterOpenCodeRestart(options?: {
|
||||||
await performFullConfigRefresh(options);
|
message?: string;
|
||||||
|
delayMs?: number;
|
||||||
|
scopes?: ConfigChangeScope[];
|
||||||
|
mode?: ConfigRefreshMode;
|
||||||
|
}) {
|
||||||
|
await performConfigRefresh(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reloadOpenCodeConfiguration(options?: { message?: string; delayMs?: number }) {
|
export async function reloadOpenCodeConfiguration(options?: {
|
||||||
|
message?: string;
|
||||||
|
delayMs?: number;
|
||||||
|
scopes?: ConfigChangeScope[];
|
||||||
|
mode?: ConfigRefreshMode;
|
||||||
|
}) {
|
||||||
startConfigUpdate(options?.message || "Reloading OpenCode configuration…");
|
startConfigUpdate(options?.message || "Reloading OpenCode configuration…");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -482,14 +602,20 @@ export async function reloadOpenCodeConfiguration(options?: { message?: string;
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshOptions = {
|
||||||
|
...options,
|
||||||
|
scopes: options?.scopes ?? ["all"],
|
||||||
|
mode: options?.mode ?? "projects",
|
||||||
|
};
|
||||||
|
|
||||||
if (payload?.requiresReload) {
|
if (payload?.requiresReload) {
|
||||||
await performFullConfigRefresh({
|
await refreshAfterOpenCodeRestart({
|
||||||
|
...refreshOptions,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
delayMs: payload.reloadDelayMs,
|
delayMs: payload.reloadDelayMs,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
await refreshAfterOpenCodeRestart(refreshOptions);
|
||||||
await performFullConfigRefresh(options);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[reloadOpenCodeConfiguration] Failed:', error);
|
console.error('[reloadOpenCodeConfiguration] Failed:', error);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "@/lib/configUpdate";
|
} from "@/lib/configUpdate";
|
||||||
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
|
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
|
||||||
import { getSafeStorage } from "./utils/safeStorage";
|
import { getSafeStorage } from "./utils/safeStorage";
|
||||||
import { useConfigStore } from "@/stores/useConfigStore";
|
import { useProjectsStore } from "@/stores/useProjectsStore";
|
||||||
import { useDirectoryStore } from "@/stores/useDirectoryStore";
|
|
||||||
|
|
||||||
export type CommandScope = 'user' | 'project';
|
export type CommandScope = 'user' | 'project';
|
||||||
|
|
||||||
@@ -37,6 +37,29 @@ export const isCommandBuiltIn = (command: Command): boolean => {
|
|||||||
|
|
||||||
const CONFIG_EVENT_SOURCE = "useCommandsStore";
|
const CONFIG_EVENT_SOURCE = "useCommandsStore";
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const getRequestDirectory = (): string | null => {
|
||||||
|
try {
|
||||||
|
const projectsStore = useProjectsStore.getState();
|
||||||
|
const activeProject = projectsStore.getActiveProject?.();
|
||||||
|
|
||||||
|
// 1. Primary: Active project path from store
|
||||||
|
if (activeProject?.path?.trim()) {
|
||||||
|
return activeProject.path.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: current OpenCode directory (session / runtime)
|
||||||
|
const clientDir = opencodeClient.getDirectory();
|
||||||
|
if (clientDir?.trim()) {
|
||||||
|
return clientDir.trim();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[CommandsStore] Error resolving config directory:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_HEALTH_WAIT_MS = 20000;
|
const MAX_HEALTH_WAIT_MS = 20000;
|
||||||
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
|
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
|
||||||
const FAST_HEALTH_POLL_ATTEMPTS = 4;
|
const FAST_HEALTH_POLL_ATTEMPTS = 4;
|
||||||
@@ -101,30 +124,60 @@ export const useCommandsStore = create<CommandsStore>()(
|
|||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
const commands = await opencodeClient.listCommandsWithDetails();
|
const directory = getRequestDirectory();
|
||||||
|
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||||
|
|
||||||
// Fetch scope info for each command
|
// Ensure the list is scoped to the same directory we use for config source detection.
|
||||||
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
const commands = await opencodeClient.withDirectory(
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
directory,
|
||||||
|
() => opencodeClient.listCommandsWithDetails()
|
||||||
|
);
|
||||||
|
|
||||||
const commandsWithScope = await Promise.all(
|
const commandsWithScope = await Promise.all(
|
||||||
commands.map(async (cmd) => {
|
commands.map(async (cmd) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`);
|
// Force no-cache
|
||||||
|
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
...(directory ? { 'x-opencode-directory': directory } : {}),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// Handle both web (data.scope) and desktop (data.sources.md.scope) response formats
|
|
||||||
const scope = data.scope ?? data.sources?.md?.scope;
|
// Prioritize explicit scope
|
||||||
return { ...cmd, scope: scope as CommandScope | undefined };
|
let scope = data.scope;
|
||||||
|
|
||||||
|
// Fallback to deducing from sources
|
||||||
|
if (!scope && data.sources) {
|
||||||
|
const sources = data.sources;
|
||||||
|
scope = (sources.md?.exists ? sources.md.scope : undefined)
|
||||||
|
?? (sources.json?.exists ? sources.json.scope : undefined)
|
||||||
|
?? sources.md?.scope
|
||||||
|
?? sources.json?.scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'project' || scope === 'user') {
|
||||||
|
return { ...cmd, scope: scope as CommandScope };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly set null scope if not found
|
||||||
|
return { ...cmd, scope: undefined };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Ignore errors fetching scope
|
console.warn(`[CommandsStore] Failed to fetch config for command ${cmd.name}:`, err);
|
||||||
}
|
}
|
||||||
return cmd;
|
return cmd;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
set({ commands: commandsWithScope, isLoading: false });
|
if (JSON.stringify(previousCommands) !== JSON.stringify(commandsWithScope)) {
|
||||||
|
set({ commands: commandsWithScope, isLoading: false });
|
||||||
|
} else {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@@ -156,13 +209,15 @@ export const useCommandsStore = create<CommandsStore>()(
|
|||||||
|
|
||||||
console.log('[CommandsStore] Command config to save:', commandConfig);
|
console.log('[CommandsStore] Command config to save:', commandConfig);
|
||||||
|
|
||||||
// Get current directory for project-level command support
|
const directory = getRequestDirectory();
|
||||||
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
|
||||||
|
|
||||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, {
|
const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(directory ? { 'x-opencode-directory': directory } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify(commandConfig)
|
body: JSON.stringify(commandConfig)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,13 +271,15 @@ export const useCommandsStore = create<CommandsStore>()(
|
|||||||
|
|
||||||
console.log('[CommandsStore] Command config to update:', commandConfig);
|
console.log('[CommandsStore] Command config to update:', commandConfig);
|
||||||
|
|
||||||
// Get current directory for project-level command support
|
const directory = getRequestDirectory();
|
||||||
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
|
||||||
|
|
||||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(directory ? { 'x-opencode-directory': directory } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify(commandConfig)
|
body: JSON.stringify(commandConfig)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,12 +320,13 @@ export const useCommandsStore = create<CommandsStore>()(
|
|||||||
startConfigUpdate("Deleting command configuration…");
|
startConfigUpdate("Deleting command configuration…");
|
||||||
let requiresReload = false;
|
let requiresReload = false;
|
||||||
try {
|
try {
|
||||||
// Get current directory for project-level command support
|
// Use active project root for project-level command support
|
||||||
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
const directory = getRequestDirectory();
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||||
|
|
||||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: directory ? { 'x-opencode-directory': directory } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
@@ -380,31 +438,23 @@ async function performFullConfigRefresh(options: { message?: string; delayMs?: n
|
|||||||
const { message, delayMs } = options;
|
const { message, delayMs } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
updateConfigUpdateMessage(message || "Reloading OpenCode configuration…");
|
updateConfigUpdateMessage(message || "Refreshing commands…");
|
||||||
if (typeof window !== "undefined" && window.localStorage) {
|
} catch {
|
||||||
window.localStorage.removeItem("commands-store");
|
// ignore
|
||||||
window.localStorage.removeItem("config-store");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[CommandsStore] Failed to prepare config refresh:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForOpenCodeConnection(delayMs);
|
await waitForOpenCodeConnection(delayMs);
|
||||||
updateConfigUpdateMessage("Refreshing providers and commands…");
|
updateConfigUpdateMessage("Refreshing commands…");
|
||||||
|
|
||||||
const configStore = useConfigStore.getState();
|
|
||||||
const commandsStore = useCommandsStore.getState();
|
const commandsStore = useCommandsStore.getState();
|
||||||
|
|
||||||
await Promise.all([
|
await commandsStore.loadCommands();
|
||||||
configStore.loadProviders().then(() => undefined),
|
|
||||||
commandsStore.loadCommands().then(() => undefined),
|
|
||||||
]);
|
|
||||||
|
|
||||||
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
|
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CommandsStore] Failed to refresh configuration after OpenCode restart:", error);
|
console.error("[CommandsStore] Failed to refresh configuration after OpenCode restart:", error);
|
||||||
updateConfigUpdateMessage("OpenCode reload failed. Please retry refreshing configuration manually.");
|
updateConfigUpdateMessage("OpenCode refresh failed. Please retry refreshing configuration manually.");
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
} finally {
|
} finally {
|
||||||
finishConfigUpdate();
|
finishConfigUpdate();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { filterVisibleAgents } from "./useAgentsStore";
|
|||||||
import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop";
|
import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop";
|
||||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
|
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
|
||||||
import { updateDesktopSettings } from "@/lib/persistence";
|
import { updateDesktopSettings } from "@/lib/persistence";
|
||||||
|
import { useDirectoryStore } from "@/stores/useDirectoryStore";
|
||||||
|
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
|
||||||
|
|
||||||
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
||||||
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
|
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
|
||||||
@@ -271,10 +273,70 @@ const fetchModelsDevMetadata = async (): Promise<Map<string, ModelMetadata>> =>
|
|||||||
return new Map();
|
return new Map();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let modelsMetadataInFlight: Promise<Map<string, ModelMetadata>> | null = null;
|
||||||
|
|
||||||
|
const ensureModelsMetadataFetch = (
|
||||||
|
getModelsMetadata: () => Map<string, ModelMetadata>,
|
||||||
|
setModelsMetadata: (metadata: Map<string, ModelMetadata>) => void,
|
||||||
|
) => {
|
||||||
|
const existing = getModelsMetadata();
|
||||||
|
if (existing.size > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelsMetadataInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modelsMetadataInFlight = fetchModelsDevMetadata()
|
||||||
|
.then((metadata) => {
|
||||||
|
if (metadata.size > 0) {
|
||||||
|
setModelsMetadata(metadata);
|
||||||
|
}
|
||||||
|
return metadata;
|
||||||
|
})
|
||||||
|
.catch(() => new Map<string, ModelMetadata>())
|
||||||
|
.finally(() => {
|
||||||
|
modelsMetadataInFlight = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const DIRECTORY_KEY_GLOBAL = "__global__";
|
||||||
|
|
||||||
|
const toDirectoryKey = (directory: string | null | undefined): string => {
|
||||||
|
const trimmed = typeof directory === 'string' ? directory.trim() : '';
|
||||||
|
return trimmed.length > 0 ? trimmed : DIRECTORY_KEY_GLOBAL;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromDirectoryKey = (key: string): string | null => (key === DIRECTORY_KEY_GLOBAL ? null : key);
|
||||||
|
|
||||||
|
const resolveInitialDirectoryKey = (): string => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return DIRECTORY_KEY_GLOBAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory;
|
||||||
|
return toDirectoryKey(directory);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DirectoryScopedConfig {
|
||||||
|
providers: ProviderWithModelList[];
|
||||||
|
agents: Agent[];
|
||||||
|
currentProviderId: string;
|
||||||
|
currentModelId: string;
|
||||||
|
currentAgentName: string | undefined;
|
||||||
|
selectedProviderId: string;
|
||||||
|
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
|
||||||
|
defaultProviders: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
interface ConfigStore {
|
interface ConfigStore {
|
||||||
|
|
||||||
|
activeDirectoryKey: string;
|
||||||
|
directoryScoped: Record<string, DirectoryScopedConfig>;
|
||||||
|
|
||||||
providers: ProviderWithModelList[];
|
providers: ProviderWithModelList[];
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
currentProviderId: string;
|
currentProviderId: string;
|
||||||
@@ -290,8 +352,10 @@ interface ConfigStore {
|
|||||||
settingsDefaultModel: string | undefined; // format: "provider/model"
|
settingsDefaultModel: string | undefined; // format: "provider/model"
|
||||||
settingsDefaultAgent: string | undefined;
|
settingsDefaultAgent: string | undefined;
|
||||||
|
|
||||||
loadProviders: () => Promise<void>;
|
activateDirectory: (directory: string | null | undefined) => Promise<void>;
|
||||||
loadAgents: () => Promise<boolean>;
|
|
||||||
|
loadProviders: (options?: { directory?: string | null }) => Promise<void>;
|
||||||
|
loadAgents: (options?: { directory?: string | null }) => Promise<boolean>;
|
||||||
setProvider: (providerId: string) => void;
|
setProvider: (providerId: string) => void;
|
||||||
setModel: (modelId: string) => void;
|
setModel: (modelId: string) => void;
|
||||||
setAgent: (agentName: string | undefined) => void;
|
setAgent: (agentName: string | undefined) => void;
|
||||||
@@ -322,6 +386,9 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
|
|
||||||
|
activeDirectoryKey: resolveInitialDirectoryKey(),
|
||||||
|
directoryScoped: {},
|
||||||
|
|
||||||
providers: [],
|
providers: [],
|
||||||
agents: [],
|
agents: [],
|
||||||
currentProviderId: "",
|
currentProviderId: "",
|
||||||
@@ -336,15 +403,63 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
settingsDefaultModel: undefined,
|
settingsDefaultModel: undefined,
|
||||||
settingsDefaultAgent: undefined,
|
settingsDefaultAgent: undefined,
|
||||||
|
|
||||||
loadProviders: async () => {
|
activateDirectory: async (directory) => {
|
||||||
const previousProviders = get().providers;
|
const directoryKey = toDirectoryKey(directory);
|
||||||
const previousDefaults = get().defaultProviders;
|
|
||||||
|
set((state) => {
|
||||||
|
const snapshot = state.directoryScoped[directoryKey];
|
||||||
|
if (snapshot) {
|
||||||
|
return {
|
||||||
|
activeDirectoryKey: directoryKey,
|
||||||
|
providers: snapshot.providers,
|
||||||
|
agents: snapshot.agents,
|
||||||
|
currentProviderId: snapshot.currentProviderId,
|
||||||
|
currentModelId: snapshot.currentModelId,
|
||||||
|
currentAgentName: snapshot.currentAgentName,
|
||||||
|
selectedProviderId: snapshot.selectedProviderId,
|
||||||
|
agentModelSelections: snapshot.agentModelSelections,
|
||||||
|
defaultProviders: snapshot.defaultProviders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeDirectoryKey: directoryKey,
|
||||||
|
providers: [],
|
||||||
|
agents: [],
|
||||||
|
currentProviderId: "",
|
||||||
|
currentModelId: "",
|
||||||
|
currentAgentName: undefined,
|
||||||
|
selectedProviderId: "",
|
||||||
|
agentModelSelections: {},
|
||||||
|
defaultProviders: {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!get().isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await get().loadProviders({ directory: fromDirectoryKey(directoryKey) });
|
||||||
|
await get().loadAgents({ directory: fromDirectoryKey(directoryKey) });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadProviders: async (options) => {
|
||||||
|
const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey));
|
||||||
|
const existingSnapshot = get().directoryScoped[directoryKey];
|
||||||
|
const previousProviders = existingSnapshot?.providers ?? (get().activeDirectoryKey === directoryKey ? get().providers : []);
|
||||||
|
const previousDefaults = existingSnapshot?.defaultProviders ?? (get().activeDirectoryKey === directoryKey ? get().defaultProviders : {});
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
const metadataPromise = fetchModelsDevMetadata();
|
ensureModelsMetadataFetch(
|
||||||
const apiResult = await opencodeClient.getProviders();
|
() => get().modelsMetadata,
|
||||||
|
(metadata) => set({ modelsMetadata: metadata }),
|
||||||
|
);
|
||||||
|
const apiResult = await opencodeClient.withDirectory(
|
||||||
|
fromDirectoryKey(directoryKey),
|
||||||
|
() => opencodeClient.getProviders()
|
||||||
|
);
|
||||||
const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : [];
|
const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : [];
|
||||||
const defaults = apiResult?.default || {};
|
const defaults = apiResult?.default || {};
|
||||||
|
|
||||||
@@ -357,16 +472,39 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only store providers and defaults - model/agent selection handled in loadAgents
|
set((state) => {
|
||||||
set({
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
providers: processedProviders,
|
providers: [],
|
||||||
defaultProviders: defaults,
|
agents: [],
|
||||||
|
currentProviderId: "",
|
||||||
|
currentModelId: "",
|
||||||
|
currentAgentName: undefined,
|
||||||
|
selectedProviderId: "",
|
||||||
|
agentModelSelections: {},
|
||||||
|
defaultProviders: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
providers: processedProviders,
|
||||||
|
defaultProviders: defaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextState: Partial<ConfigStore> = {
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.activeDirectoryKey === directoryKey) {
|
||||||
|
nextState.providers = processedProviders;
|
||||||
|
nextState.defaultProviders = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
});
|
});
|
||||||
|
|
||||||
const metadata = await metadataPromise;
|
|
||||||
if (metadata.size > 0) {
|
|
||||||
set({ modelsMetadata: metadata });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@@ -376,10 +514,38 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error("Failed to load providers:", lastError);
|
console.error("Failed to load providers:", lastError);
|
||||||
// Preserve previous state on failure instead of clearing it
|
|
||||||
set({
|
set((state) => {
|
||||||
providers: previousProviders,
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
defaultProviders: previousDefaults,
|
providers: [],
|
||||||
|
agents: [],
|
||||||
|
currentProviderId: "",
|
||||||
|
currentModelId: "",
|
||||||
|
currentAgentName: undefined,
|
||||||
|
selectedProviderId: "",
|
||||||
|
agentModelSelections: {},
|
||||||
|
defaultProviders: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
providers: previousProviders,
|
||||||
|
defaultProviders: previousDefaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextState: Partial<ConfigStore> = {
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.activeDirectoryKey === directoryKey) {
|
||||||
|
nextState.providers = previousProviders;
|
||||||
|
nextState.defaultProviders = previousDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -387,34 +553,135 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
const { providers } = get();
|
const { providers } = get();
|
||||||
const provider = providers.find((p) => p.id === providerId);
|
const provider = providers.find((p) => p.id === providerId);
|
||||||
|
|
||||||
if (provider) {
|
if (!provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const firstModel = provider.models[0];
|
const firstModel = provider.models[0];
|
||||||
const newModelId = firstModel?.id || "";
|
const newModelId = firstModel?.id || "";
|
||||||
|
|
||||||
set({
|
set((state) => {
|
||||||
|
const directoryKey = state.activeDirectoryKey;
|
||||||
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
|
providers: state.providers,
|
||||||
|
agents: state.agents,
|
||||||
|
currentProviderId: state.currentProviderId,
|
||||||
|
currentModelId: state.currentModelId,
|
||||||
|
currentAgentName: state.currentAgentName,
|
||||||
|
selectedProviderId: state.selectedProviderId,
|
||||||
|
agentModelSelections: state.agentModelSelections,
|
||||||
|
defaultProviders: state.defaultProviders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
currentProviderId: providerId,
|
currentProviderId: providerId,
|
||||||
currentModelId: newModelId,
|
currentModelId: newModelId,
|
||||||
selectedProviderId: providerId,
|
selectedProviderId: providerId,
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
currentProviderId: providerId,
|
||||||
|
currentModelId: newModelId,
|
||||||
|
selectedProviderId: providerId,
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setModel: (modelId: string) => {
|
setModel: (modelId: string) => {
|
||||||
set({ currentModelId: modelId });
|
set((state) => {
|
||||||
|
const directoryKey = state.activeDirectoryKey;
|
||||||
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
|
providers: state.providers,
|
||||||
|
agents: state.agents,
|
||||||
|
currentProviderId: state.currentProviderId,
|
||||||
|
currentModelId: state.currentModelId,
|
||||||
|
currentAgentName: state.currentAgentName,
|
||||||
|
selectedProviderId: state.selectedProviderId,
|
||||||
|
agentModelSelections: state.agentModelSelections,
|
||||||
|
defaultProviders: state.defaultProviders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
currentModelId: modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentModelId: modelId,
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setSelectedProvider: (providerId: string) => {
|
setSelectedProvider: (providerId: string) => {
|
||||||
set({ selectedProviderId: providerId });
|
set((state) => {
|
||||||
|
const directoryKey = state.activeDirectoryKey;
|
||||||
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
|
providers: state.providers,
|
||||||
|
agents: state.agents,
|
||||||
|
currentProviderId: state.currentProviderId,
|
||||||
|
currentModelId: state.currentModelId,
|
||||||
|
currentAgentName: state.currentAgentName,
|
||||||
|
selectedProviderId: state.selectedProviderId,
|
||||||
|
agentModelSelections: state.agentModelSelections,
|
||||||
|
defaultProviders: state.defaultProviders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
selectedProviderId: providerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedProviderId: providerId,
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => {
|
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => {
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
agentModelSelections: {
|
const directoryKey = state.activeDirectoryKey;
|
||||||
|
const nextSelections = {
|
||||||
...state.agentModelSelections,
|
...state.agentModelSelections,
|
||||||
[agentName]: { providerId, modelId },
|
[agentName]: { providerId, modelId },
|
||||||
},
|
};
|
||||||
}));
|
|
||||||
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
|
providers: state.providers,
|
||||||
|
agents: state.agents,
|
||||||
|
currentProviderId: state.currentProviderId,
|
||||||
|
currentModelId: state.currentModelId,
|
||||||
|
currentAgentName: state.currentAgentName,
|
||||||
|
selectedProviderId: state.selectedProviderId,
|
||||||
|
agentModelSelections: state.agentModelSelections,
|
||||||
|
defaultProviders: state.defaultProviders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
agentModelSelections: nextSelections,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentModelSelections: nextSelections,
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getAgentModelSelection: (agentName: string) => {
|
getAgentModelSelection: (agentName: string) => {
|
||||||
@@ -422,40 +689,97 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
return agentModelSelections[agentName] || null;
|
return agentModelSelections[agentName] || null;
|
||||||
},
|
},
|
||||||
|
|
||||||
loadAgents: async () => {
|
loadAgents: async (options) => {
|
||||||
const previousAgents = get().agents;
|
const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey));
|
||||||
|
const existingSnapshot = get().directoryScoped[directoryKey];
|
||||||
|
const previousAgents = existingSnapshot?.agents ?? (get().activeDirectoryKey === directoryKey ? get().agents : []);
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
// Fetch agents and OpenChamber settings in parallel
|
// Fetch agents and OpenChamber settings in parallel
|
||||||
const [agents, openChamberDefaults] = await Promise.all([
|
const [agents, openChamberDefaults] = await Promise.all([
|
||||||
opencodeClient.listAgents(),
|
opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()),
|
||||||
fetchOpenChamberDefaults(),
|
fetchOpenChamberDefaults(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const safeAgents = Array.isArray(agents) ? agents : [];
|
const safeAgents = Array.isArray(agents) ? agents : [];
|
||||||
set({
|
|
||||||
agents: safeAgents,
|
const providers = get().activeDirectoryKey === directoryKey
|
||||||
// Store settings defaults so setAgent can respect them
|
? get().providers
|
||||||
settingsDefaultModel: openChamberDefaults.defaultModel,
|
: (get().directoryScoped[directoryKey]?.providers ?? []);
|
||||||
settingsDefaultAgent: openChamberDefaults.defaultAgent,
|
|
||||||
|
set((state) => {
|
||||||
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
|
providers,
|
||||||
|
agents: previousAgents,
|
||||||
|
currentProviderId: "",
|
||||||
|
currentModelId: "",
|
||||||
|
currentAgentName: undefined,
|
||||||
|
selectedProviderId: "",
|
||||||
|
agentModelSelections: {},
|
||||||
|
defaultProviders: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
providers,
|
||||||
|
agents: safeAgents,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextState: Partial<ConfigStore> = {
|
||||||
|
settingsDefaultModel: openChamberDefaults.defaultModel,
|
||||||
|
settingsDefaultAgent: openChamberDefaults.defaultAgent,
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.activeDirectoryKey === directoryKey) {
|
||||||
|
nextState.agents = safeAgents;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { providers } = get();
|
|
||||||
|
|
||||||
if (safeAgents.length === 0) {
|
if (safeAgents.length === 0) {
|
||||||
set({ currentAgentName: undefined });
|
set((state) => {
|
||||||
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
|
providers,
|
||||||
|
agents: [],
|
||||||
|
currentProviderId: "",
|
||||||
|
currentModelId: "",
|
||||||
|
currentAgentName: undefined,
|
||||||
|
selectedProviderId: "",
|
||||||
|
agentModelSelections: {},
|
||||||
|
defaultProviders: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
providers,
|
||||||
|
agents: [],
|
||||||
|
currentAgentName: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextState: Partial<ConfigStore> = {
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.activeDirectoryKey === directoryKey) {
|
||||||
|
nextState.currentAgentName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Agent Selection ---
|
|
||||||
// Priority: settings.defaultAgent → build → first primary → first agent
|
|
||||||
const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode));
|
|
||||||
const buildAgent = primaryAgents.find((agent) => agent.name === "build");
|
|
||||||
const fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0];
|
|
||||||
|
|
||||||
let resolvedAgent: Agent | undefined;
|
|
||||||
|
|
||||||
// Helper to validate model exists in providers
|
// Helper to validate model exists in providers
|
||||||
const validateModel = (providerId: string, modelId: string): boolean => {
|
const validateModel = (providerId: string, modelId: string): boolean => {
|
||||||
const provider = providers.find((p) => p.id === providerId);
|
const provider = providers.find((p) => p.id === providerId);
|
||||||
@@ -463,6 +787,14 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
return provider.models.some((m) => m.id === modelId);
|
return provider.models.some((m) => m.id === modelId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Agent Selection ---
|
||||||
|
// Priority: settings.defaultAgent → build → first primary → first agent
|
||||||
|
const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode));
|
||||||
|
const buildAgent = primaryAgents.find((agent) => agent.name === "build");
|
||||||
|
const fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0];
|
||||||
|
|
||||||
|
let resolvedAgent: Agent = fallbackAgent;
|
||||||
|
|
||||||
// Track invalid settings to clear
|
// Track invalid settings to clear
|
||||||
const invalidSettings: { defaultModel?: string; defaultAgent?: string } = {};
|
const invalidSettings: { defaultModel?: string; defaultAgent?: string } = {};
|
||||||
|
|
||||||
@@ -477,13 +809,6 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fall back to default logic
|
|
||||||
if (!resolvedAgent) {
|
|
||||||
resolvedAgent = fallbackAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ currentAgentName: resolvedAgent.name });
|
|
||||||
|
|
||||||
// --- Model Selection ---
|
// --- Model Selection ---
|
||||||
// Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle
|
// Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle
|
||||||
let resolvedProviderId: string | undefined;
|
let resolvedProviderId: string | undefined;
|
||||||
@@ -524,12 +849,44 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedProviderId && resolvedModelId) {
|
set((state) => {
|
||||||
set({
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
currentProviderId: resolvedProviderId,
|
providers,
|
||||||
currentModelId: resolvedModelId,
|
agents: safeAgents,
|
||||||
});
|
currentProviderId: "",
|
||||||
}
|
currentModelId: "",
|
||||||
|
currentAgentName: undefined,
|
||||||
|
selectedProviderId: "",
|
||||||
|
agentModelSelections: {},
|
||||||
|
defaultProviders: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
providers,
|
||||||
|
agents: safeAgents,
|
||||||
|
currentAgentName: resolvedAgent.name,
|
||||||
|
currentProviderId: resolvedProviderId ?? baseSnapshot.currentProviderId,
|
||||||
|
currentModelId: resolvedModelId ?? baseSnapshot.currentModelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextState: Partial<ConfigStore> = {
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.activeDirectoryKey === directoryKey) {
|
||||||
|
nextState.currentAgentName = resolvedAgent.name;
|
||||||
|
if (resolvedProviderId && resolvedModelId) {
|
||||||
|
nextState.currentProviderId = resolvedProviderId;
|
||||||
|
nextState.currentModelId = resolvedModelId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
|
||||||
// Clear invalid settings from storage (best-effort cleanup)
|
// Clear invalid settings from storage (best-effort cleanup)
|
||||||
if (Object.keys(invalidSettings).length > 0) {
|
if (Object.keys(invalidSettings).length > 0) {
|
||||||
@@ -552,14 +909,75 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error("Failed to load agents:", lastError);
|
console.error("Failed to load agents:", lastError);
|
||||||
set({ agents: previousAgents });
|
|
||||||
|
set((state) => {
|
||||||
|
const providers = state.activeDirectoryKey === directoryKey
|
||||||
|
? state.providers
|
||||||
|
: (state.directoryScoped[directoryKey]?.providers ?? []);
|
||||||
|
|
||||||
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
|
providers,
|
||||||
|
agents: [],
|
||||||
|
currentProviderId: "",
|
||||||
|
currentModelId: "",
|
||||||
|
currentAgentName: undefined,
|
||||||
|
selectedProviderId: "",
|
||||||
|
agentModelSelections: {},
|
||||||
|
defaultProviders: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
providers,
|
||||||
|
agents: previousAgents,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextState: Partial<ConfigStore> = {
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.activeDirectoryKey === directoryKey) {
|
||||||
|
nextState.agents = previousAgents;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
setAgent: (agentName: string | undefined) => {
|
setAgent: (agentName: string | undefined) => {
|
||||||
const { agents, providers, settingsDefaultModel } = get();
|
const { agents, providers, settingsDefaultModel } = get();
|
||||||
|
|
||||||
set({ currentAgentName: agentName });
|
set((state) => {
|
||||||
|
const directoryKey = state.activeDirectoryKey;
|
||||||
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
|
providers: state.providers,
|
||||||
|
agents: state.agents,
|
||||||
|
currentProviderId: state.currentProviderId,
|
||||||
|
currentModelId: state.currentModelId,
|
||||||
|
currentAgentName: state.currentAgentName,
|
||||||
|
selectedProviderId: state.selectedProviderId,
|
||||||
|
agentModelSelections: state.agentModelSelections,
|
||||||
|
defaultProviders: state.defaultProviders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
currentAgentName: agentName,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentAgentName: agentName,
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (agentName && typeof window !== "undefined") {
|
if (agentName && typeof window !== "undefined") {
|
||||||
|
|
||||||
@@ -608,9 +1026,33 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
if (parsed) {
|
if (parsed) {
|
||||||
const settingsProvider = providers.find((p) => p.id === parsed.providerId);
|
const settingsProvider = providers.find((p) => p.id === parsed.providerId);
|
||||||
if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) {
|
if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) {
|
||||||
set({
|
set((state) => {
|
||||||
currentProviderId: parsed.providerId,
|
const directoryKey = state.activeDirectoryKey;
|
||||||
currentModelId: parsed.modelId,
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
|
providers: state.providers,
|
||||||
|
agents: state.agents,
|
||||||
|
currentProviderId: state.currentProviderId,
|
||||||
|
currentModelId: state.currentModelId,
|
||||||
|
currentAgentName: state.currentAgentName,
|
||||||
|
selectedProviderId: state.selectedProviderId,
|
||||||
|
agentModelSelections: state.agentModelSelections,
|
||||||
|
defaultProviders: state.defaultProviders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
currentProviderId: parsed.providerId,
|
||||||
|
currentModelId: parsed.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentProviderId: parsed.providerId,
|
||||||
|
currentModelId: parsed.modelId,
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -625,10 +1067,35 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
const agentModel = agentProvider.models.find((model) => model.id === agent.model!.modelID);
|
const agentModel = agentProvider.models.find((model) => model.id === agent.model!.modelID);
|
||||||
|
|
||||||
if (agentModel) {
|
if (agentModel) {
|
||||||
set({
|
set((state) => {
|
||||||
currentProviderId: agent.model!.providerID,
|
const directoryKey = state.activeDirectoryKey;
|
||||||
currentModelId: agent.model!.modelID,
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
selectedProviderId: agent.model!.providerID,
|
providers: state.providers,
|
||||||
|
agents: state.agents,
|
||||||
|
currentProviderId: state.currentProviderId,
|
||||||
|
currentModelId: state.currentModelId,
|
||||||
|
currentAgentName: state.currentAgentName,
|
||||||
|
selectedProviderId: state.selectedProviderId,
|
||||||
|
agentModelSelections: state.agentModelSelections,
|
||||||
|
defaultProviders: state.defaultProviders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSnapshot: DirectoryScopedConfig = {
|
||||||
|
...baseSnapshot,
|
||||||
|
currentProviderId: agent.model!.providerID,
|
||||||
|
currentModelId: agent.model!.modelID,
|
||||||
|
selectedProviderId: agent.model!.providerID,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentProviderId: agent.model!.providerID,
|
||||||
|
currentModelId: agent.model!.modelID,
|
||||||
|
selectedProviderId: agent.model!.providerID,
|
||||||
|
directoryScoped: {
|
||||||
|
...state.directoryScoped,
|
||||||
|
[directoryKey]: nextSnapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -671,28 +1138,29 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
|
|
||||||
initializeApp: async () => {
|
initializeApp: async () => {
|
||||||
try {
|
try {
|
||||||
console.log("Starting app initialization...");
|
const debug = streamDebugEnabled();
|
||||||
|
if (debug) console.log("Starting app initialization...");
|
||||||
|
|
||||||
const isConnected = await get().checkConnection();
|
const isConnected = await get().checkConnection();
|
||||||
console.log("Connection check result:", isConnected);
|
if (debug) console.log("Connection check result:", isConnected);
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
console.log("Server not connected");
|
if (debug) console.log("Server not connected");
|
||||||
set({ isConnected: false });
|
set({ isConnected: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Initializing app...");
|
if (debug) console.log("Initializing app...");
|
||||||
await opencodeClient.initApp();
|
await opencodeClient.initApp();
|
||||||
|
|
||||||
console.log("Loading providers...");
|
if (debug) console.log("Loading providers...");
|
||||||
await get().loadProviders();
|
await get().loadProviders();
|
||||||
|
|
||||||
console.log("Loading agents...");
|
if (debug) console.log("Loading agents...");
|
||||||
await get().loadAgents();
|
await get().loadAgents();
|
||||||
|
|
||||||
set({ isInitialized: true, isConnected: true });
|
set({ isInitialized: true, isConnected: true });
|
||||||
console.log("App initialized successfully");
|
if (debug) console.log("App initialized successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize app:", error);
|
console.error("Failed to initialize app:", error);
|
||||||
set({ isInitialized: false, isConnected: false });
|
set({ isInitialized: false, isConnected: false });
|
||||||
@@ -770,3 +1238,17 @@ if (!unsubscribeConfigStoreChanges) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let unsubscribeConfigStoreDirectoryChanges: (() => void) | null = null;
|
||||||
|
|
||||||
|
if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) {
|
||||||
|
unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => {
|
||||||
|
const nextKey = toDirectoryKey(state.currentDirectory);
|
||||||
|
const prevKey = toDirectoryKey(prevState.currentDirectory);
|
||||||
|
if (nextKey === prevKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void useConfigStore.getState().activateDirectory(state.currentDirectory);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import type { DirectorySwitchResult } from '@/lib/opencode/client';
|
|
||||||
import { getDesktopHomeDirectory } from '@/lib/desktop';
|
import { getDesktopHomeDirectory } from '@/lib/desktop';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate';
|
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
|
||||||
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
|
|
||||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
|
||||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
import { emitConfigChange } from '@/lib/configSync';
|
|
||||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||||
import { getSafeStorage } from './utils/safeStorage';
|
import { getSafeStorage } from './utils/safeStorage';
|
||||||
|
|
||||||
@@ -37,89 +31,6 @@ const persistedLastDirectory = safeStorage.getItem('lastDirectory');
|
|||||||
const initialHasPersistedDirectory =
|
const initialHasPersistedDirectory =
|
||||||
typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0;
|
typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0;
|
||||||
|
|
||||||
const notifyOpenCodeWorkingDirectory = (path: string, options?: { showOverlay?: boolean }) => {
|
|
||||||
const showOverlay = options?.showOverlay ?? true;
|
|
||||||
if (showOverlay) {
|
|
||||||
startConfigUpdate('Switching project directory…');
|
|
||||||
}
|
|
||||||
|
|
||||||
return opencodeClient.setOpenCodeWorkingDirectory(path).catch((error) => {
|
|
||||||
console.warn('Failed to synchronize OpenCode working directory:', error);
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleDirectoryFollowUp = (
|
|
||||||
restartPromise: Promise<DirectorySwitchResult | null>,
|
|
||||||
options: { showOverlay: boolean },
|
|
||||||
onComplete?: (result: DirectorySwitchResult | null) => void
|
|
||||||
) => {
|
|
||||||
const { showOverlay } = options;
|
|
||||||
|
|
||||||
const reloadSessions = () => {
|
|
||||||
try {
|
|
||||||
useSessionStore.getState().loadSessions();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to reload sessions after directory change:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
let result: DirectorySwitchResult | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
result = await restartPromise;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update OpenCode working directory:', error);
|
|
||||||
if (showOverlay) {
|
|
||||||
updateConfigUpdateMessage('Failed to switch directory. Please try again.');
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
||||||
finishConfigUpdate();
|
|
||||||
}
|
|
||||||
onComplete?.(result);
|
|
||||||
reloadSessions();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (result && result.restarted) {
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined' && window.localStorage) {
|
|
||||||
window.localStorage.removeItem('commands-store');
|
|
||||||
}
|
|
||||||
} catch (storageError) {
|
|
||||||
console.warn('Failed to reset commands-store cache:', storageError);
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshAfterOpenCodeRestart({ message: 'Refreshing OpenCode configuration…' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await useCommandsStore.getState().loadCommands();
|
|
||||||
|
|
||||||
try {
|
|
||||||
emitConfigChange('commands', { source: 'useCommandsStore' });
|
|
||||||
} catch (syncError) {
|
|
||||||
console.warn('Failed to emit command configuration change:', syncError);
|
|
||||||
}
|
|
||||||
} catch (commandError) {
|
|
||||||
console.warn('Failed to reload commands after directory change:', commandError);
|
|
||||||
}
|
|
||||||
} else if (showOverlay) {
|
|
||||||
finishConfigUpdate();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh configuration after directory change:', error);
|
|
||||||
if (showOverlay) {
|
|
||||||
updateConfigUpdateMessage('Failed to refresh configuration. Please reload manually.');
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
||||||
finishConfigUpdate();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
onComplete?.(result);
|
|
||||||
reloadSessions();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
};
|
|
||||||
|
|
||||||
const invalidateFileSearchCache = (scope?: string | null) => {
|
const invalidateFileSearchCache = (scope?: string | null) => {
|
||||||
try {
|
try {
|
||||||
@@ -295,26 +206,20 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
isSwitchingDirectory: false,
|
isSwitchingDirectory: false,
|
||||||
|
|
||||||
setDirectory: (path: string, options?: { showOverlay?: boolean }) => {
|
setDirectory: (path: string, options?: { showOverlay?: boolean }) => {
|
||||||
|
void options;
|
||||||
const homeDir = cachedHomeDirectory || get().homeDirectory || safeStorage.getItem('homeDirectory');
|
const homeDir = cachedHomeDirectory || get().homeDirectory || safeStorage.getItem('homeDirectory');
|
||||||
const resolvedPath = resolveDirectoryPath(path, homeDir);
|
const resolvedPath = resolveDirectoryPath(path, homeDir);
|
||||||
if (streamDebugEnabled()) {
|
if (streamDebugEnabled()) {
|
||||||
console.log('[DirectoryStore] setDirectory called with path:', resolvedPath);
|
console.log('[DirectoryStore] setDirectory called with path:', resolvedPath);
|
||||||
}
|
}
|
||||||
const showOverlay = options?.showOverlay ?? true;
|
|
||||||
|
|
||||||
opencodeClient.setDirectory(resolvedPath);
|
opencodeClient.setDirectory(resolvedPath);
|
||||||
invalidateFileSearchCache();
|
invalidateFileSearchCache();
|
||||||
const restartPromise = notifyOpenCodeWorkingDirectory(resolvedPath, { showOverlay });
|
|
||||||
if (streamDebugEnabled()) {
|
|
||||||
console.log('[DirectoryStore] notifyOpenCodeWorkingDirectory initiated');
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|
||||||
const newHistory = [...state.directoryHistory.slice(0, state.historyIndex + 1), resolvedPath];
|
const newHistory = [...state.directoryHistory.slice(0, state.historyIndex + 1), resolvedPath];
|
||||||
|
|
||||||
safeStorage.setItem('lastDirectory', resolvedPath);
|
safeStorage.setItem('lastDirectory', resolvedPath);
|
||||||
|
|
||||||
void updateDesktopSettings({ lastDirectory: resolvedPath });
|
void updateDesktopSettings({ lastDirectory: resolvedPath });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -323,21 +228,9 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
historyIndex: newHistory.length - 1,
|
historyIndex: newHistory.length - 1,
|
||||||
hasPersistedDirectory: true,
|
hasPersistedDirectory: true,
|
||||||
isHomeReady: true,
|
isHomeReady: true,
|
||||||
isSwitchingDirectory: true,
|
isSwitchingDirectory: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
scheduleDirectoryFollowUp(restartPromise, { showOverlay }, () => {
|
|
||||||
set((state) => {
|
|
||||||
if (state.currentDirectory !== resolvedPath) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (!state.isSwitchingDirectory) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return { isSwitchingDirectory: false };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
goBack: () => {
|
goBack: () => {
|
||||||
@@ -348,7 +241,6 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
|
|
||||||
opencodeClient.setDirectory(newDirectory);
|
opencodeClient.setDirectory(newDirectory);
|
||||||
invalidateFileSearchCache();
|
invalidateFileSearchCache();
|
||||||
const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory);
|
|
||||||
|
|
||||||
safeStorage.setItem('lastDirectory', newDirectory);
|
safeStorage.setItem('lastDirectory', newDirectory);
|
||||||
|
|
||||||
@@ -359,19 +251,7 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
historyIndex: newIndex,
|
historyIndex: newIndex,
|
||||||
hasPersistedDirectory: true,
|
hasPersistedDirectory: true,
|
||||||
isHomeReady: true,
|
isHomeReady: true,
|
||||||
isSwitchingDirectory: true,
|
isSwitchingDirectory: false,
|
||||||
});
|
|
||||||
|
|
||||||
scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => {
|
|
||||||
set((state) => {
|
|
||||||
if (state.currentDirectory !== newDirectory) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (!state.isSwitchingDirectory) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return { isSwitchingDirectory: false };
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -384,7 +264,6 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
|
|
||||||
opencodeClient.setDirectory(newDirectory);
|
opencodeClient.setDirectory(newDirectory);
|
||||||
invalidateFileSearchCache();
|
invalidateFileSearchCache();
|
||||||
const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory);
|
|
||||||
|
|
||||||
safeStorage.setItem('lastDirectory', newDirectory);
|
safeStorage.setItem('lastDirectory', newDirectory);
|
||||||
|
|
||||||
@@ -395,19 +274,7 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
historyIndex: newIndex,
|
historyIndex: newIndex,
|
||||||
hasPersistedDirectory: true,
|
hasPersistedDirectory: true,
|
||||||
isHomeReady: true,
|
isHomeReady: true,
|
||||||
isSwitchingDirectory: true,
|
isSwitchingDirectory: false,
|
||||||
});
|
|
||||||
|
|
||||||
scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => {
|
|
||||||
set((state) => {
|
|
||||||
if (state.currentDirectory !== newDirectory) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (!state.isSwitchingDirectory) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return { isSwitchingDirectory: false };
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -484,12 +351,12 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
updates.currentDirectory = resolvedHome;
|
updates.currentDirectory = resolvedHome;
|
||||||
updates.directoryHistory = [resolvedHome];
|
updates.directoryHistory = [resolvedHome];
|
||||||
updates.historyIndex = 0;
|
updates.historyIndex = 0;
|
||||||
updates.isSwitchingDirectory = true;
|
updates.isSwitchingDirectory = false;
|
||||||
} else if (currentChanged || historyChanged) {
|
} else if (currentChanged || historyChanged) {
|
||||||
updates.currentDirectory = resolvedCurrent as string;
|
updates.currentDirectory = resolvedCurrent as string;
|
||||||
updates.directoryHistory = resolvedHistory;
|
updates.directoryHistory = resolvedHistory;
|
||||||
updates.historyIndex = Math.min(state.historyIndex, resolvedHistory.length - 1);
|
updates.historyIndex = Math.min(state.historyIndex, resolvedHistory.length - 1);
|
||||||
updates.isSwitchingDirectory = true;
|
updates.isSwitchingDirectory = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
set(() => updates as Partial<DirectoryStore>);
|
set(() => updates as Partial<DirectoryStore>);
|
||||||
@@ -501,18 +368,6 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
safeStorage.setItem('lastDirectory', nextDirectory);
|
safeStorage.setItem('lastDirectory', nextDirectory);
|
||||||
void updateDesktopSettings({ lastDirectory: nextDirectory });
|
void updateDesktopSettings({ lastDirectory: nextDirectory });
|
||||||
|
|
||||||
const restartPromise = notifyOpenCodeWorkingDirectory(nextDirectory, { showOverlay: false });
|
|
||||||
scheduleDirectoryFollowUp(restartPromise, { showOverlay: false }, () => {
|
|
||||||
set((state) => {
|
|
||||||
if (state.currentDirectory !== nextDirectory) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (!state.isSwitchingDirectory) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return { isSwitchingDirectory: false };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateDesktopSettings({ homeDirectory: resolvedHome });
|
void updateDesktopSettings({ homeDirectory: resolvedHome });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createWorktree } from '@/lib/git/worktreeService';
|
|||||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
import { useSessionStore } from './sessionStore';
|
import { useSessionStore } from './sessionStore';
|
||||||
import { useDirectoryStore } from './useDirectoryStore';
|
import { useDirectoryStore } from './useDirectoryStore';
|
||||||
|
import { useProjectsStore } from './useProjectsStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a git-safe slug from a string.
|
* Generate a git-safe slug from a string.
|
||||||
@@ -48,8 +49,33 @@ const sanitizeWorktreeSlug = (value: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const getCurrentDirectory = (): string | null => {
|
const resolveProjectDirectory = (): string | null => {
|
||||||
return useDirectoryStore.getState().currentDirectory ?? null;
|
const projectsState = useProjectsStore.getState();
|
||||||
|
const activeProjectId = projectsState.activeProjectId;
|
||||||
|
const activeProjectPath = activeProjectId
|
||||||
|
? projectsState.projects.find((project) => project.id === activeProjectId)?.path
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) {
|
||||||
|
return activeProjectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null;
|
||||||
|
if (!currentDirectory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory;
|
||||||
|
const marker = '/.openchamber/';
|
||||||
|
const markerIndex = normalized.indexOf(marker);
|
||||||
|
if (markerIndex > 0) {
|
||||||
|
return normalized.slice(0, markerIndex);
|
||||||
|
}
|
||||||
|
if (normalized.endsWith('/.openchamber')) {
|
||||||
|
return normalized.slice(0, normalized.length - '/.openchamber'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MultiRunState {
|
interface MultiRunState {
|
||||||
@@ -99,7 +125,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const directory = getCurrentDirectory();
|
const directory = resolveProjectDirectory();
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
set({ error: 'No directory selected', isLoading: false });
|
set({ error: 'No directory selected', isLoading: false });
|
||||||
return null;
|
return null;
|
||||||
@@ -236,7 +262,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
return { sessionIds, firstSessionId };
|
return { groupSlug, sessionIds, firstSessionId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : 'Failed to create Multi-Run',
|
error: error instanceof Error ? error.message : 'Failed to create Multi-Run',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
|||||||
import type { StoreApi, UseBoundStore } from "zustand";
|
import type { StoreApi, UseBoundStore } from "zustand";
|
||||||
import { devtools } from "zustand/middleware";
|
import { devtools } from "zustand/middleware";
|
||||||
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
|
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
|
||||||
import type { Permission, PermissionResponse } from "@/types/permission";
|
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
|
||||||
import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes";
|
import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes";
|
||||||
import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } from "./types/sessionTypes";
|
import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } from "./types/sessionTypes";
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
|
|
||||||
sessions: [],
|
sessions: [],
|
||||||
|
sessionsByDirectory: new Map(),
|
||||||
currentSessionId: null,
|
currentSessionId: null,
|
||||||
lastLoadedDirectory: null,
|
lastLoadedDirectory: null,
|
||||||
messages: new Map(),
|
messages: new Map(),
|
||||||
@@ -87,6 +88,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
webUICreatedSessions: new Set(),
|
webUICreatedSessions: new Set(),
|
||||||
worktreeMetadata: new Map(),
|
worktreeMetadata: new Map(),
|
||||||
availableWorktrees: [],
|
availableWorktrees: [],
|
||||||
|
availableWorktreesByProject: new Map(),
|
||||||
currentAgentContext: new Map(),
|
currentAgentContext: new Map(),
|
||||||
sessionContextUsage: new Map(),
|
sessionContextUsage: new Map(),
|
||||||
sessionAgentEditModes: new Map(),
|
sessionAgentEditModes: new Map(),
|
||||||
@@ -420,7 +422,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
markMessageStreamSettled: (messageId: string) => useMessageStore.getState().markMessageStreamSettled(messageId),
|
markMessageStreamSettled: (messageId: string) => useMessageStore.getState().markMessageStreamSettled(messageId),
|
||||||
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record<string, unknown>) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo),
|
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record<string, unknown>) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo),
|
||||||
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null),
|
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null),
|
||||||
addPermission: (permission: Permission) => {
|
addPermission: (permission: PermissionRequest) => {
|
||||||
const contextData = {
|
const contextData = {
|
||||||
currentAgentContext: useContextStore.getState().currentAgentContext,
|
currentAgentContext: useContextStore.getState().currentAgentContext,
|
||||||
sessionAgentSelections: useContextStore.getState().sessionAgentSelections,
|
sessionAgentSelections: useContextStore.getState().sessionAgentSelections,
|
||||||
@@ -428,9 +430,10 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
};
|
};
|
||||||
return usePermissionStore.getState().addPermission(permission, contextData);
|
return usePermissionStore.getState().addPermission(permission, contextData);
|
||||||
},
|
},
|
||||||
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, permissionId, response),
|
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, requestId, response),
|
||||||
clearError: () => useSessionManagementStore.getState().clearError(),
|
clearError: () => useSessionManagementStore.getState().clearError(),
|
||||||
getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory),
|
getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory),
|
||||||
|
getDirectoryForSession: (sessionId: string) => useSessionManagementStore.getState().getDirectoryForSession(sessionId),
|
||||||
getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId),
|
getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId),
|
||||||
getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId),
|
getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId),
|
||||||
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => useMessageStore.getState().syncMessages(sessionId, messages),
|
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => useMessageStore.getState().syncMessages(sessionId, messages),
|
||||||
@@ -507,6 +510,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
return useContextStore.getState().pollForTokenUpdates(sessionId, messageId, messages, maxAttempts);
|
return useContextStore.getState().pollForTokenUpdates(sessionId, messageId, messages, maxAttempts);
|
||||||
},
|
},
|
||||||
updateSession: (session: Session) => useSessionManagementStore.getState().updateSession(session),
|
updateSession: (session: Session) => useSessionManagementStore.getState().updateSession(session),
|
||||||
|
removeSessionFromStore: (sessionId: string) => useSessionManagementStore.getState().removeSessionFromStore(sessionId),
|
||||||
|
|
||||||
revertToMessage: async (sessionId: string, messageId: string) => {
|
revertToMessage: async (sessionId: string, messageId: string) => {
|
||||||
// Get the message text before reverting
|
// Get the message text before reverting
|
||||||
@@ -559,7 +563,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
const sessions = get().sessions;
|
const sessions = get().sessions;
|
||||||
const currentSession = sessions.find(s => s.id === sessionId);
|
const currentSession = sessions.find(s => s.id === sessionId);
|
||||||
|
|
||||||
// Silent no-op like OpenCode CLI
|
// No-op when there is nothing to undo/redo
|
||||||
if (userMessages.length === 0) {
|
if (userMessages.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -576,7 +580,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
targetMessage = userMessages[userMessages.length - 1];
|
targetMessage = userMessages[userMessages.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Silent no-op like OpenCode CLI
|
// No-op when there is nothing to undo/redo
|
||||||
if (!targetMessage) {
|
if (!targetMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -598,7 +602,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
const currentSession = sessions.find(s => s.id === sessionId);
|
const currentSession = sessions.find(s => s.id === sessionId);
|
||||||
const revertToId = currentSession?.revert?.messageID;
|
const revertToId = currentSession?.revert?.messageID;
|
||||||
|
|
||||||
// Silent no-op like OpenCode CLI
|
// No-op when there is nothing to undo/redo
|
||||||
if (!revertToId) {
|
if (!revertToId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -708,13 +712,15 @@ useSessionManagementStore.subscribe((state, prevState) => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
state.sessions === prevState.sessions &&
|
state.sessions === prevState.sessions &&
|
||||||
|
state.sessionsByDirectory === prevState.sessionsByDirectory &&
|
||||||
state.currentSessionId === prevState.currentSessionId &&
|
state.currentSessionId === prevState.currentSessionId &&
|
||||||
state.lastLoadedDirectory === prevState.lastLoadedDirectory &&
|
state.lastLoadedDirectory === prevState.lastLoadedDirectory &&
|
||||||
state.isLoading === prevState.isLoading &&
|
state.isLoading === prevState.isLoading &&
|
||||||
state.error === prevState.error &&
|
state.error === prevState.error &&
|
||||||
state.webUICreatedSessions === prevState.webUICreatedSessions &&
|
state.webUICreatedSessions === prevState.webUICreatedSessions &&
|
||||||
state.worktreeMetadata === prevState.worktreeMetadata &&
|
state.worktreeMetadata === prevState.worktreeMetadata &&
|
||||||
state.availableWorktrees === prevState.availableWorktrees
|
state.availableWorktrees === prevState.availableWorktrees &&
|
||||||
|
state.availableWorktreesByProject === prevState.availableWorktreesByProject
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -723,6 +729,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
|
|||||||
|
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
sessions: state.sessions,
|
sessions: state.sessions,
|
||||||
|
sessionsByDirectory: state.sessionsByDirectory,
|
||||||
currentSessionId: draftOpen ? null : state.currentSessionId,
|
currentSessionId: draftOpen ? null : state.currentSessionId,
|
||||||
lastLoadedDirectory: state.lastLoadedDirectory,
|
lastLoadedDirectory: state.lastLoadedDirectory,
|
||||||
isLoading: state.isLoading,
|
isLoading: state.isLoading,
|
||||||
@@ -730,6 +737,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
|
|||||||
webUICreatedSessions: state.webUICreatedSessions,
|
webUICreatedSessions: state.webUICreatedSessions,
|
||||||
worktreeMetadata: state.worktreeMetadata,
|
worktreeMetadata: state.worktreeMetadata,
|
||||||
availableWorktrees: state.availableWorktrees,
|
availableWorktrees: state.availableWorktrees,
|
||||||
|
availableWorktreesByProject: state.availableWorktreesByProject,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -873,6 +881,7 @@ useSessionStore.setState({
|
|||||||
webUICreatedSessions: useSessionManagementStore.getState().webUICreatedSessions,
|
webUICreatedSessions: useSessionManagementStore.getState().webUICreatedSessions,
|
||||||
worktreeMetadata: useSessionManagementStore.getState().worktreeMetadata,
|
worktreeMetadata: useSessionManagementStore.getState().worktreeMetadata,
|
||||||
availableWorktrees: useSessionManagementStore.getState().availableWorktrees,
|
availableWorktrees: useSessionManagementStore.getState().availableWorktrees,
|
||||||
|
availableWorktreesByProject: useSessionManagementStore.getState().availableWorktreesByProject,
|
||||||
messages: useMessageStore.getState().messages,
|
messages: useMessageStore.getState().messages,
|
||||||
sessionMemoryState: useMessageStore.getState().sessionMemoryState,
|
sessionMemoryState: useMessageStore.getState().sessionMemoryState,
|
||||||
messageStreamStates: useMessageStore.getState().messageStreamStates,
|
messageStreamStates: useMessageStore.getState().messageStreamStates,
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ import type {
|
|||||||
} from '@/lib/api/types';
|
} from '@/lib/api/types';
|
||||||
|
|
||||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
|
||||||
const getCurrentDirectory = (): string | null => {
|
const getCurrentDirectory = (): string | null => {
|
||||||
|
const opencodeDirectory = opencodeClient.getDirectory();
|
||||||
|
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
|
||||||
|
return opencodeDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const store = (window as any).__zustand_directory_store__;
|
const store = (window as any).__zustand_directory_store__;
|
||||||
@@ -24,6 +30,7 @@ const getCurrentDirectory = (): string | null => {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ import {
|
|||||||
} from "@/lib/configUpdate";
|
} from "@/lib/configUpdate";
|
||||||
import { getSafeStorage } from "./utils/safeStorage";
|
import { getSafeStorage } from "./utils/safeStorage";
|
||||||
|
|
||||||
// Access directory store without circular dependency
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
|
||||||
const getCurrentDirectory = (): string | null => {
|
const getCurrentDirectory = (): string | null => {
|
||||||
|
const opencodeDirectory = opencodeClient.getDirectory();
|
||||||
|
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
|
||||||
|
return opencodeDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const store = (window as any).__zustand_directory_store__;
|
const store = (window as any).__zustand_directory_store__;
|
||||||
@@ -19,6 +25,7 @@ const getCurrentDirectory = (): string | null => {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||||||
import { devtools } from "zustand/middleware";
|
import { devtools } from "zustand/middleware";
|
||||||
|
|
||||||
import { opencodeClient } from "@/lib/opencode/client";
|
import { opencodeClient } from "@/lib/opencode/client";
|
||||||
|
import { useSessionStore } from "./useSessionStore";
|
||||||
|
|
||||||
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
|
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
|
||||||
export type TodoPriority = "high" | "medium" | "low";
|
export type TodoPriority = "high" | "medium" | "low";
|
||||||
@@ -46,7 +47,10 @@ export const useTodoStore = create<TodoStore>()(
|
|||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawTodos = await opencodeClient.getSessionTodos(sessionId);
|
const directory = useSessionStore.getState().getDirectoryForSession(sessionId);
|
||||||
|
const rawTodos = directory
|
||||||
|
? await opencodeClient.withDirectory(directory, () => opencodeClient.getSessionTodos(sessionId))
|
||||||
|
: await opencodeClient.getSessionTodos(sessionId);
|
||||||
const todos = rawTodos.map(normalizeTodo);
|
const todos = rawTodos.map(normalizeTodo);
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
@@ -15,14 +15,33 @@ export const isEditPermissionType = (type?: string | null): boolean => {
|
|||||||
return EDIT_PERMISSION_TOOL_NAMES.has(type.toLowerCase());
|
return EDIT_PERMISSION_TOOL_NAMES.has(type.toLowerCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveConfigStore = () => {
|
type PermissionAction = 'allow' | 'deny' | 'ask';
|
||||||
|
|
||||||
|
type PermissionRule = {
|
||||||
|
permission: string;
|
||||||
|
pattern: string;
|
||||||
|
action: PermissionAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfigStoreAgent = {
|
||||||
|
name: string;
|
||||||
|
permission?: PermissionRule[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfigStoreState = {
|
||||||
|
agents?: ConfigStoreAgent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfigStoreRef = { getState?: () => ConfigStoreState };
|
||||||
|
|
||||||
|
const resolveConfigStore = (): ConfigStoreRef | undefined => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return (window as { __zustand_config_store__?: { getState?: () => { agents?: Array<{ name: string; permission?: { edit?: string }; tools?: { edit?: boolean } }> } } }).__zustand_config_store__;
|
return (window as { __zustand_config_store__?: ConfigStoreRef }).__zustand_config_store__;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAgentDefinition = (agentName?: string): { name: string; permission?: { edit?: string }; tools?: { edit?: boolean } } | undefined => {
|
const getAgentDefinition = (agentName?: string): ConfigStoreAgent | undefined => {
|
||||||
if (!agentName) {
|
if (!agentName) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -31,24 +50,45 @@ const getAgentDefinition = (agentName?: string): { name: string; permission?: {
|
|||||||
const configStore = resolveConfigStore();
|
const configStore = resolveConfigStore();
|
||||||
if (configStore?.getState) {
|
if (configStore?.getState) {
|
||||||
const state = configStore.getState();
|
const state = configStore.getState();
|
||||||
return state.agents?.find?.((agent: { name: string; permission?: { edit?: string }; tools?: { edit?: boolean } }) => agent.name === agentName);
|
return state.agents?.find?.((agent) => agent.name === agentName);
|
||||||
}
|
}
|
||||||
} catch { /* ignored */ }
|
} catch {
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolvePermissionAction = (ruleset: PermissionRule[] | undefined, permission: string): PermissionAction => {
|
||||||
|
if (!ruleset || ruleset.length === 0) {
|
||||||
|
return 'ask';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer explicit rule for the tool at wildcard pattern.
|
||||||
|
for (let index = ruleset.length - 1; index >= 0; index -= 1) {
|
||||||
|
const rule = ruleset[index];
|
||||||
|
if (rule.permission === permission && rule.pattern === '*') {
|
||||||
|
return rule.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to global wildcard.
|
||||||
|
for (let index = ruleset.length - 1; index >= 0; index -= 1) {
|
||||||
|
const rule = ruleset[index];
|
||||||
|
if (rule.permission === '*' && rule.pattern === '*') {
|
||||||
|
return rule.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ask';
|
||||||
|
};
|
||||||
|
|
||||||
export const getAgentDefaultEditPermission = (agentName?: string): EditPermissionMode => {
|
export const getAgentDefaultEditPermission = (agentName?: string): EditPermissionMode => {
|
||||||
const agent = getAgentDefinition(agentName);
|
const agent = getAgentDefinition(agentName);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return 'ask';
|
return 'ask';
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission = agent.permission?.edit;
|
const action = resolvePermissionAction(agent.permission, 'edit');
|
||||||
if (permission === 'allow' || permission === 'ask' || permission === 'deny' || permission === 'full') {
|
return action;
|
||||||
return permission;
|
|
||||||
}
|
|
||||||
|
|
||||||
const editToolEnabled = agent.tools ? agent.tools.edit !== false : true;
|
|
||||||
return editToolEnabled ? 'ask' : 'deny';
|
|
||||||
};
|
};
|
||||||
@@ -36,6 +36,8 @@ export interface CreateMultiRunParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMultiRunResult {
|
export interface CreateMultiRunResult {
|
||||||
|
/** Canonical group slug used in session titles */
|
||||||
|
groupSlug: string;
|
||||||
/** Session IDs created successfully (in selection order) */
|
/** Session IDs created successfully (in selection order) */
|
||||||
sessionIds: string[];
|
sessionIds: string[];
|
||||||
/** First successfully created session ID, if any */
|
/** First successfully created session ID, if any */
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
export interface Permission {
|
export interface PermissionRequest {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
|
||||||
pattern?: string | string[];
|
|
||||||
patterns?: string[]; // New system: array of specific patterns requesting approval
|
|
||||||
always?: string[]; // New system: what will be auto-approved on "always" click
|
|
||||||
sessionID: string;
|
sessionID: string;
|
||||||
messageID: string;
|
permission: string;
|
||||||
callID?: string;
|
patterns: string[];
|
||||||
title: string;
|
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
time: {
|
always: string[];
|
||||||
created: number;
|
|
||||||
};
|
|
||||||
tool?: {
|
tool?: {
|
||||||
messageID: string;
|
messageID: string;
|
||||||
callID: string;
|
callID: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionEvent {
|
export type PermissionResponse = 'once' | 'always' | 'reject';
|
||||||
type: 'permission.updated';
|
|
||||||
properties: Permission;
|
export interface PermissionAskedEvent {
|
||||||
|
type: 'permission.asked';
|
||||||
|
properties: PermissionRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PermissionResponse = 'once' | 'always' | 'reject';
|
export interface PermissionRepliedEvent {
|
||||||
|
type: 'permission.replied';
|
||||||
|
properties: {
|
||||||
|
sessionID: string;
|
||||||
|
requestID: string;
|
||||||
|
reply: PermissionResponse;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.0.209",
|
"@opencode-ai/sdk": "^1.1.1",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class AgentManagerPanelProvider {
|
|||||||
private _cachedError?: string;
|
private _cachedError?: string;
|
||||||
private _sseCounter = 0;
|
private _sseCounter = 0;
|
||||||
private _sseStreams = new Map<string, AbortController>();
|
private _sseStreams = new Map<string, AbortController>();
|
||||||
|
private _sseHeartbeats = new Map<string, ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _context: vscode.ExtensionContext,
|
private readonly _context: vscode.ExtensionContext,
|
||||||
@@ -58,6 +59,12 @@ export class AgentManagerPanelProvider {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
}
|
}
|
||||||
this._sseStreams.clear();
|
this._sseStreams.clear();
|
||||||
|
|
||||||
|
for (const heartbeat of this._sseHeartbeats.values()) {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
}
|
||||||
|
this._sseHeartbeats.clear();
|
||||||
|
|
||||||
this._panel = undefined;
|
this._panel = undefined;
|
||||||
}, null, this._context.subscriptions);
|
}, null, this._context.subscriptions);
|
||||||
|
|
||||||
@@ -162,12 +169,29 @@ export class AgentManagerPanelProvider {
|
|||||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
let wrapAsGlobal = false;
|
||||||
|
|
||||||
|
const requestHeaders = this._buildSseHeaders(headers || {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(targetUrl, {
|
response = await fetch(targetUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: this._buildSseHeaders(headers || {}),
|
headers: requestHeaders,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fallback for OpenCode versions without /global/event.
|
||||||
|
if ((!response.ok || !response.body) && normalizedPath === '/global/event') {
|
||||||
|
const fallbackUrl = new URL('event', base).toString();
|
||||||
|
response = await fetch(fallbackUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: requestHeaders,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (response.ok && response.body) {
|
||||||
|
wrapAsGlobal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return {
|
return {
|
||||||
@@ -196,6 +220,21 @@ export class AgentManagerPanelProvider {
|
|||||||
|
|
||||||
this._sseStreams.set(streamId, controller);
|
this._sseStreams.set(streamId, controller);
|
||||||
|
|
||||||
|
const fallbackDirectory = this._openCodeManager?.getWorkingDirectory()
|
||||||
|
|| vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
|
|| 'global';
|
||||||
|
|
||||||
|
if (shouldInjectActivity) {
|
||||||
|
const heartbeatTimer = setInterval(() => {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`;
|
||||||
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk });
|
||||||
|
}, 30000);
|
||||||
|
this._sseHeartbeats.set(streamId, heartbeatTimer);
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const reader = responseBody.getReader();
|
const reader = responseBody.getReader();
|
||||||
@@ -216,7 +255,10 @@ export class AgentManagerPanelProvider {
|
|||||||
const blocks = sseBuffer.split('\n\n');
|
const blocks = sseBuffer.split('\n\n');
|
||||||
sseBuffer = blocks.pop() ?? '';
|
sseBuffer = blocks.pop() ?? '';
|
||||||
if (blocks.length > 0) {
|
if (blocks.length > 0) {
|
||||||
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(blocks) : blocks;
|
const routedBlocks = wrapAsGlobal
|
||||||
|
? wrapSseBlocksAsGlobal(blocks, fallbackDirectory)
|
||||||
|
: blocks;
|
||||||
|
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks;
|
||||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||||
}
|
}
|
||||||
@@ -229,7 +271,10 @@ export class AgentManagerPanelProvider {
|
|||||||
}
|
}
|
||||||
if (sseBuffer) {
|
if (sseBuffer) {
|
||||||
if (shouldInjectActivity) {
|
if (shouldInjectActivity) {
|
||||||
const outboundBlocks = expandSseBlocksWithActivity([sseBuffer]);
|
const baseBlocks = wrapAsGlobal
|
||||||
|
? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory)
|
||||||
|
: [sseBuffer];
|
||||||
|
const outboundBlocks = expandSseBlocksWithActivity(baseBlocks);
|
||||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||||
} else {
|
} else {
|
||||||
@@ -252,6 +297,11 @@ export class AgentManagerPanelProvider {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this._sseStreams.delete(streamId);
|
this._sseStreams.delete(streamId);
|
||||||
|
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||||
|
if (heartbeat) {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
this._sseHeartbeats.delete(streamId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -276,6 +326,12 @@ export class AgentManagerPanelProvider {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
this._sseStreams.delete(streamId);
|
this._sseStreams.delete(streamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||||
|
if (heartbeat) {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
this._sseHeartbeats.delete(streamId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { id, type, success: true, data: { stopped: true } };
|
return { id, type, success: true, data: { stopped: true } };
|
||||||
}
|
}
|
||||||
@@ -396,6 +452,80 @@ const buildActivityEventBlock = (activity: SessionActivity): string => {
|
|||||||
})}`;
|
})}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildHeartbeatEventBlock = (): string => {
|
||||||
|
return `data: ${JSON.stringify({ type: 'openchamber:heartbeat', timestamp: Date.now() })}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSseBlockForGlobalWrap = (block: string): { id?: string; payload: Record<string, unknown> } | null => {
|
||||||
|
if (!block) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = block.split('\n');
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
let eventId: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).replace(/^\s/, ''));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('id:')) {
|
||||||
|
const candidate = line.slice(3).trim();
|
||||||
|
if (candidate) {
|
||||||
|
eventId = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadText = dataLines.join('\n').trim();
|
||||||
|
if (!payloadText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadText) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const nestedPayload = record.payload;
|
||||||
|
const payload = nestedPayload && typeof nestedPayload === 'object'
|
||||||
|
? (nestedPayload as Record<string, unknown>)
|
||||||
|
: record;
|
||||||
|
|
||||||
|
return eventId ? { id: eventId, payload } : { payload };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => {
|
||||||
|
const normalizedDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||||
|
? directory.trim().replace(/\\/g, '/')
|
||||||
|
: 'global';
|
||||||
|
|
||||||
|
return blocks.map((block) => {
|
||||||
|
const parsed = parseSseBlockForGlobalWrap(block);
|
||||||
|
if (!parsed) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = {
|
||||||
|
directory: normalizedDirectory,
|
||||||
|
payload: parsed.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
const idPrefix = parsed.id ? `id: ${parsed.id}\n` : '';
|
||||||
|
return `${idPrefix}data: ${JSON.stringify(envelope)}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
||||||
const expanded: string[] = [];
|
const expanded: string[] = [];
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
private _cachedError?: string;
|
private _cachedError?: string;
|
||||||
private _sseCounter = 0;
|
private _sseCounter = 0;
|
||||||
private _sseStreams = new Map<string, AbortController>();
|
private _sseStreams = new Map<string, AbortController>();
|
||||||
|
private _sseHeartbeats = new Map<string, ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _context: vscode.ExtensionContext,
|
private readonly _context: vscode.ExtensionContext,
|
||||||
@@ -195,12 +196,30 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
let wrapAsGlobal = false;
|
||||||
|
|
||||||
|
const requestHeaders = this._buildSseHeaders(headers || {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(targetUrl, {
|
response = await fetch(targetUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: this._buildSseHeaders(headers || {}),
|
headers: requestHeaders,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fallback: OpenCode versions without /global/event.
|
||||||
|
// VS Code is single-workspace, so we can wrap /event into { directory, payload }.
|
||||||
|
if ((!response.ok || !response.body) && normalizedPath === '/global/event') {
|
||||||
|
const fallbackUrl = new URL('event', base).toString();
|
||||||
|
response = await fetch(fallbackUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: requestHeaders,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (response.ok && response.body) {
|
||||||
|
wrapAsGlobal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return {
|
return {
|
||||||
@@ -229,6 +248,21 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
|
|
||||||
this._sseStreams.set(streamId, controller);
|
this._sseStreams.set(streamId, controller);
|
||||||
|
|
||||||
|
const fallbackDirectory = this._openCodeManager?.getWorkingDirectory()
|
||||||
|
|| vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
|
|| 'global';
|
||||||
|
|
||||||
|
if (shouldInjectActivity) {
|
||||||
|
const heartbeatTimer = setInterval(() => {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`;
|
||||||
|
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk });
|
||||||
|
}, 30000);
|
||||||
|
this._sseHeartbeats.set(streamId, heartbeatTimer);
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const reader = responseBody.getReader();
|
const reader = responseBody.getReader();
|
||||||
@@ -251,7 +285,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
const blocks = sseBuffer.split('\n\n');
|
const blocks = sseBuffer.split('\n\n');
|
||||||
sseBuffer = blocks.pop() ?? '';
|
sseBuffer = blocks.pop() ?? '';
|
||||||
if (blocks.length > 0) {
|
if (blocks.length > 0) {
|
||||||
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(blocks) : blocks;
|
const routedBlocks = wrapAsGlobal
|
||||||
|
? wrapSseBlocksAsGlobal(blocks, fallbackDirectory)
|
||||||
|
: blocks;
|
||||||
|
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks;
|
||||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||||
}
|
}
|
||||||
@@ -264,7 +301,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
}
|
}
|
||||||
if (sseBuffer) {
|
if (sseBuffer) {
|
||||||
if (shouldInjectActivity) {
|
if (shouldInjectActivity) {
|
||||||
const outboundBlocks = expandSseBlocksWithActivity([sseBuffer]);
|
const baseBlocks = wrapAsGlobal
|
||||||
|
? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory)
|
||||||
|
: [sseBuffer];
|
||||||
|
const outboundBlocks = expandSseBlocksWithActivity(baseBlocks);
|
||||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||||
} else {
|
} else {
|
||||||
@@ -287,6 +327,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this._sseStreams.delete(streamId);
|
this._sseStreams.delete(streamId);
|
||||||
|
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||||
|
if (heartbeat) {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
this._sseHeartbeats.delete(streamId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -311,6 +356,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
this._sseStreams.delete(streamId);
|
this._sseStreams.delete(streamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||||
|
if (heartbeat) {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
this._sseHeartbeats.delete(streamId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { id, type, success: true, data: { stopped: true } };
|
return { id, type, success: true, data: { stopped: true } };
|
||||||
}
|
}
|
||||||
@@ -432,6 +483,80 @@ const buildActivityEventBlock = (activity: SessionActivity): string => {
|
|||||||
})}`;
|
})}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildHeartbeatEventBlock = (): string => {
|
||||||
|
return `data: ${JSON.stringify({ type: 'openchamber:heartbeat', timestamp: Date.now() })}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSseBlockForGlobalWrap = (block: string): { id?: string; payload: Record<string, unknown> } | null => {
|
||||||
|
if (!block) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = block.split('\n');
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
let eventId: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).replace(/^\s/, ''));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('id:')) {
|
||||||
|
const candidate = line.slice(3).trim();
|
||||||
|
if (candidate) {
|
||||||
|
eventId = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadText = dataLines.join('\n').trim();
|
||||||
|
if (!payloadText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadText) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const nestedPayload = record.payload;
|
||||||
|
const payload = nestedPayload && typeof nestedPayload === 'object'
|
||||||
|
? (nestedPayload as Record<string, unknown>)
|
||||||
|
: record;
|
||||||
|
|
||||||
|
return eventId ? { id: eventId, payload } : { payload };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => {
|
||||||
|
const normalizedDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||||
|
? directory.trim().replace(/\\/g, '/')
|
||||||
|
: 'global';
|
||||||
|
|
||||||
|
return blocks.map((block) => {
|
||||||
|
const parsed = parseSseBlockForGlobalWrap(block);
|
||||||
|
if (!parsed) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = {
|
||||||
|
directory: normalizedDirectory,
|
||||||
|
payload: parsed.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
const idPrefix = parsed.id ? `id: ${parsed.id}\n` : '';
|
||||||
|
return `${idPrefix}data: ${JSON.stringify(envelope)}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
||||||
const expanded: string[] = [];
|
const expanded: string[] = [];
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
|
|||||||
@@ -618,23 +618,28 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'api:config/agents': {
|
case 'api:config/agents': {
|
||||||
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown>; directory?: string };
|
||||||
const agentName = typeof name === 'string' ? name.trim() : '';
|
const agentName = typeof name === 'string' ? name.trim() : '';
|
||||||
if (!agentName) {
|
if (!agentName) {
|
||||||
return { id, type, success: false, error: 'Agent name is required' };
|
return { id, type, success: false, error: 'Agent name is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get working directory for project-level agent support
|
// Use directory from request if provided, otherwise fall back to workspace
|
||||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
const workingDirectory = (typeof directory === 'string' && directory.trim())
|
||||||
|
? directory.trim()
|
||||||
|
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath);
|
||||||
|
|
||||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||||
if (normalizedMethod === 'GET') {
|
if (normalizedMethod === 'GET') {
|
||||||
const sources = getAgentSources(agentName, workingDirectory);
|
const sources = getAgentSources(agentName, workingDirectory);
|
||||||
|
const scope = sources.md.exists
|
||||||
|
? sources.md.scope
|
||||||
|
: (sources.json.exists ? sources.json.scope : null);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: { name: agentName, sources, scope: sources.md.scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,23 +698,28 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'api:config/commands': {
|
case 'api:config/commands': {
|
||||||
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown>; directory?: string };
|
||||||
const commandName = typeof name === 'string' ? name.trim() : '';
|
const commandName = typeof name === 'string' ? name.trim() : '';
|
||||||
if (!commandName) {
|
if (!commandName) {
|
||||||
return { id, type, success: false, error: 'Command name is required' };
|
return { id, type, success: false, error: 'Command name is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get working directory for project-level command support
|
// Use directory from request if provided, otherwise fall back to workspace
|
||||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
const workingDirectory = (typeof directory === 'string' && directory.trim())
|
||||||
|
? directory.trim()
|
||||||
|
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath);
|
||||||
|
|
||||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||||
if (normalizedMethod === 'GET') {
|
if (normalizedMethod === 'GET') {
|
||||||
const sources = getCommandSources(commandName, workingDirectory);
|
const sources = getCommandSources(commandName, workingDirectory);
|
||||||
|
const scope = sources.md.exists
|
||||||
|
? sources.md.scope
|
||||||
|
: (sources.json.exists ? sources.json.scope : null);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: { name: commandName, sources, scope: sources.md.scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -305,9 +305,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
});
|
});
|
||||||
const elapsedMs = Date.now() - startedAt;
|
const elapsedMs = Date.now() - startedAt;
|
||||||
const contentType = resp.headers.get('content-type') || '';
|
const contentType = resp.headers.get('content-type') || '';
|
||||||
|
const isJson = contentType.toLowerCase().includes('json') && !contentType.toLowerCase().includes('text/html');
|
||||||
|
|
||||||
let summary = '';
|
let summary = '';
|
||||||
if (contentType.includes('application/json')) {
|
if (isJson) {
|
||||||
const json = await resp.json().catch(() => null);
|
const json = await resp.json().catch(() => null);
|
||||||
if (Array.isArray(json)) {
|
if (Array.isArray(json)) {
|
||||||
summary = `json[array] len=${json.length}`;
|
summary = `json[array] len=${json.length}`;
|
||||||
@@ -321,7 +322,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
summary = contentType ? `content-type=${contentType}` : 'no content-type';
|
summary = contentType ? `content-type=${contentType}` : 'no content-type';
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: resp.ok, status: resp.status, elapsedMs, summary };
|
return { ok: resp.ok && isJson, status: resp.status, elapsedMs, summary };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const elapsedMs = Date.now() - startedAt;
|
const elapsedMs = Date.now() - startedAt;
|
||||||
const isAbort =
|
const isAbort =
|
||||||
@@ -356,6 +357,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
{ label: 'commands', path: '/command', includeDirectory: true },
|
{ label: 'commands', path: '/command', includeDirectory: true },
|
||||||
{ label: 'project', path: '/project/current', includeDirectory: true },
|
{ label: 'project', path: '/project/current', includeDirectory: true },
|
||||||
{ label: 'path', path: '/path', includeDirectory: true },
|
{ label: 'path', path: '/path', includeDirectory: true },
|
||||||
|
// Session listing is what powers the sidebar. This helps diagnose "no sessions shown" bugs.
|
||||||
|
{ label: 'sessions', path: '/session', includeDirectory: true, timeoutMs: 8000 },
|
||||||
{ label: 'sessionStatus', path: '/session/status', includeDirectory: true },
|
{ label: 'sessionStatus', path: '/session/status', includeDirectory: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+195
-140
@@ -1,15 +1,16 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { spawn, ChildProcess, spawnSync } from 'child_process';
|
import { spawn, ChildProcess, spawnSync } from 'child_process';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as net from 'net';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
|
||||||
// Optimized timeouts for faster startup
|
// Optimized timeouts for faster startup
|
||||||
const PORT_DETECTION_TIMEOUT_MS = 10000;
|
const READY_CHECK_TIMEOUT_MS = 30000;
|
||||||
const READY_CHECK_TIMEOUT_MS = 12000;
|
const READY_CHECK_INTERVAL_MS = 250; // Avoid hammering the server during startup
|
||||||
const READY_CHECK_INTERVAL_MS = 100; // Fast polling during startup
|
|
||||||
const HEALTH_CHECK_INTERVAL_MS = 5000;
|
const HEALTH_CHECK_INTERVAL_MS = 5000;
|
||||||
const SHUTDOWN_TIMEOUT_MS = 3000;
|
const SHUTDOWN_TIMEOUT_MS = 3000;
|
||||||
|
const DEFAULT_OPENCODE_PORT = 4096;
|
||||||
|
|
||||||
// Regex to detect port from CLI output (matches desktop pattern)
|
// Regex to detect port from CLI output (matches desktop pattern)
|
||||||
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
|
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
|
||||||
@@ -65,13 +66,35 @@ export interface OpenCodeManager {
|
|||||||
|
|
||||||
function isExecutable(filePath: string): boolean {
|
function isExecutable(filePath: string): boolean {
|
||||||
try {
|
try {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
if (!stats.isFile()) return false;
|
||||||
|
if (process.platform === 'win32') return true;
|
||||||
fs.accessSync(filePath, fs.constants.X_OK);
|
fs.accessSync(filePath, fs.constants.X_OK);
|
||||||
return fs.statSync(filePath).isFile();
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveBinaryFromPath(binaryName: string, searchPath: string): string | null {
|
||||||
|
if (!binaryName) return null;
|
||||||
|
if (path.isAbsolute(binaryName)) {
|
||||||
|
return isExecutable(binaryName) ? binaryName : null;
|
||||||
|
}
|
||||||
|
const directories = searchPath.split(path.delimiter).filter(Boolean);
|
||||||
|
for (const directory of directories) {
|
||||||
|
try {
|
||||||
|
const candidate = path.join(directory, binaryName);
|
||||||
|
if (isExecutable(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getLoginShellPath(): string | null {
|
function getLoginShellPath(): string | null {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return null;
|
return null;
|
||||||
@@ -126,17 +149,47 @@ function buildAugmentedPath(): string {
|
|||||||
function resolveCliPath(): string | null {
|
function resolveCliPath(): string | null {
|
||||||
// First check explicit candidates
|
// First check explicit candidates
|
||||||
for (const candidate of BIN_CANDIDATES) {
|
for (const candidate of BIN_CANDIDATES) {
|
||||||
if (candidate && isExecutable(candidate)) {
|
if (!candidate) continue;
|
||||||
|
if (isExecutable(candidate)) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
if (process.platform === 'win32' && !candidate.toLowerCase().endsWith('.exe')) {
|
||||||
|
const withExe = `${candidate}.exe`;
|
||||||
|
if (isExecutable(withExe)) {
|
||||||
|
return withExe;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then search in augmented PATH
|
// Then search in augmented PATH
|
||||||
const augmentedPath = buildAugmentedPath();
|
const augmentedPath = buildAugmentedPath();
|
||||||
for (const segment of augmentedPath.split(path.delimiter)) {
|
if (process.platform === 'win32') {
|
||||||
const candidate = path.join(segment, 'opencode');
|
try {
|
||||||
if (isExecutable(candidate)) {
|
const result = spawnSync('where', ['opencode'], {
|
||||||
return candidate;
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, PATH: augmentedPath },
|
||||||
|
});
|
||||||
|
if (result.status === 0 && typeof result.stdout === 'string') {
|
||||||
|
const lines = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||||
|
for (const line of lines) {
|
||||||
|
if (isExecutable(line)) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromPath = resolveBinaryFromPath('opencode.exe', augmentedPath);
|
||||||
|
if (fromPath) {
|
||||||
|
return fromPath;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fromPath = resolveBinaryFromPath('opencode', augmentedPath);
|
||||||
|
if (fromPath) {
|
||||||
|
return fromPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +245,9 @@ async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||||
|
const isJson = contentType.includes('json') && !contentType.includes('text/html');
|
||||||
|
if (response.ok && isJson) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -208,6 +263,85 @@ async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appendDirectoryQuery = (url: string, directory: string | null | undefined): string => {
|
||||||
|
const dir = typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : null;
|
||||||
|
if (!dir) return url;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
parsed.searchParams.set('directory', dir);
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function checkReady(apiUrl: string, directory: string | null | undefined, quick = false): Promise<boolean> {
|
||||||
|
const normalized = apiUrl.replace(/\/+$/, '');
|
||||||
|
const targets: Array<{ path: string; timeoutMs: number }> = [
|
||||||
|
{ path: '/config', timeoutMs: quick ? 1500 : 4000 },
|
||||||
|
{ path: '/config/providers', timeoutMs: quick ? 2000 : 6000 },
|
||||||
|
{ path: '/agent', timeoutMs: quick ? 2500 : 10000 },
|
||||||
|
{ path: '/session/status', timeoutMs: quick ? 2000 : 6000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), target.timeoutMs);
|
||||||
|
try {
|
||||||
|
const url = appendDirectoryQuery(`${normalized}${target.path}`, directory);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||||
|
const isJson = contentType.includes('json') && !contentType.includes('text/html');
|
||||||
|
if (!isJson) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isTcpPortAvailable(port: number): Promise<boolean> {
|
||||||
|
if (!Number.isFinite(port) || port <= 0) return false;
|
||||||
|
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
|
||||||
|
server.once('error', () => resolve(false));
|
||||||
|
server.listen({ host: '127.0.0.1', port }, () => {
|
||||||
|
server.close(() => resolve(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEphemeralPort(): Promise<number> {
|
||||||
|
return await new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.once('error', (err) => reject(err));
|
||||||
|
server.listen({ host: '127.0.0.1', port: 0 }, () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
server.close(() => reject(new Error('Failed to allocate ephemeral port')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
||||||
let childProcess: ChildProcess | null = null;
|
let childProcess: ChildProcess | null = null;
|
||||||
@@ -224,7 +358,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
|
|
||||||
// Port detection state (like desktop)
|
// Port detection state (like desktop)
|
||||||
let detectedPort: number | null = null;
|
let detectedPort: number | null = null;
|
||||||
let portWaiters: Array<(port: number) => void> = [];
|
|
||||||
|
|
||||||
// OpenCode API prefix detection (some versions serve under /api)
|
// OpenCode API prefix detection (some versions serve under /api)
|
||||||
let apiPrefix: string = '';
|
let apiPrefix: string = '';
|
||||||
@@ -280,6 +413,31 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
return `http://localhost:${port}${prefix}`;
|
return `http://localhost:${port}${prefix}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const probeOpenCodeAtPort = async (port: number, quick = false): Promise<string | null> => {
|
||||||
|
if (!Number.isFinite(port) || port <= 0) return null;
|
||||||
|
const origin = `http://localhost:${port}`;
|
||||||
|
for (const candidate of API_PREFIX_CANDIDATES) {
|
||||||
|
const base = `${origin}${candidate}`;
|
||||||
|
if (await checkReady(base, workingDirectory, quick)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForOpenCodeReadyAtPort = async (port: number, timeoutMs: number): Promise<boolean> => {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const prefix = await probeOpenCodeAtPort(port, true);
|
||||||
|
if (prefix !== null) {
|
||||||
|
setDetectedApiPrefix(prefix);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, READY_CHECK_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const detectApiPrefixFromOutput = (text: string) => {
|
const detectApiPrefixFromOutput = (text: string) => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
URL_REGEX.lastIndex = 0;
|
URL_REGEX.lastIndex = 0;
|
||||||
@@ -297,56 +455,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractPrefixFromOpenApiDoc = (content: string): string | null => {
|
|
||||||
const match = content.match(/__OPENCODE_API_BASE__\s*=\s*['"]([^'"]+)['"]/);
|
|
||||||
if (!match?.[1]) return null;
|
|
||||||
try {
|
|
||||||
const parsed = new URL(match[1], 'http://localhost');
|
|
||||||
return normalizeApiPrefix(parsed.pathname || '');
|
|
||||||
} catch {
|
|
||||||
return normalizeApiPrefix(match[1]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const detectApiPrefix = async (port: number): Promise<string> => {
|
|
||||||
if (apiPrefixDetected) return apiPrefix;
|
|
||||||
|
|
||||||
const origin = `http://localhost:${port}`;
|
|
||||||
|
|
||||||
// Try /doc for explicit base hints first (best signal when available).
|
|
||||||
for (const candidate of API_PREFIX_CANDIDATES) {
|
|
||||||
const prefix = normalizeApiPrefix(candidate);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${origin}${prefix}/doc`, { method: 'GET', headers: { Accept: '*/*' } });
|
|
||||||
if (!response.ok) continue;
|
|
||||||
const text = await response.text();
|
|
||||||
const extracted = extractPrefixFromOpenApiDoc(text);
|
|
||||||
if (extracted !== null) {
|
|
||||||
setDetectedApiPrefix(extracted);
|
|
||||||
return apiPrefix;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: probe a stable endpoint under root vs /api.
|
|
||||||
for (const candidate of API_PREFIX_CANDIDATES) {
|
|
||||||
try {
|
|
||||||
const base = buildApiBaseUrlFromPort(port, candidate);
|
|
||||||
const response = await fetch(`${base}/config`, { method: 'GET', headers: { Accept: 'application/json' } });
|
|
||||||
if (!response.ok) continue;
|
|
||||||
await response.json().catch(() => null);
|
|
||||||
setDetectedApiPrefix(candidate);
|
|
||||||
return apiPrefix;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiPrefix;
|
|
||||||
};
|
|
||||||
|
|
||||||
function setStatus(newStatus: ConnectionStatus, error?: string) {
|
function setStatus(newStatus: ConnectionStatus, error?: string) {
|
||||||
if (status !== newStatus || lastError !== error) {
|
if (status !== newStatus || lastError !== error) {
|
||||||
status = newStatus;
|
status = newStatus;
|
||||||
@@ -359,20 +467,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setDetectedPort(port: number) {
|
function setDetectedPort(port: number) {
|
||||||
if (detectedPort !== port) {
|
detectedPort = port;
|
||||||
detectedPort = port;
|
|
||||||
|
|
||||||
// Notify all waiters
|
|
||||||
const waiters = portWaiters;
|
|
||||||
portWaiters = [];
|
|
||||||
for (const notify of waiters) {
|
|
||||||
try {
|
|
||||||
notify(port);
|
|
||||||
} catch {
|
|
||||||
// Ignore waiter errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectPortFromOutput(text: string) {
|
function detectPortFromOutput(text: string) {
|
||||||
@@ -382,6 +477,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||||
const port = parseInt(match[1], 10);
|
const port = parseInt(match[1], 10);
|
||||||
if (Number.isFinite(port) && port > 0) {
|
if (Number.isFinite(port) && port > 0) {
|
||||||
|
if (detectedPort !== null && detectedPort !== port) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDetectedPort(port);
|
setDetectedPort(port);
|
||||||
const inferred = inferPrefixFromLogPath(match[2] || '');
|
const inferred = inferPrefixFromLogPath(match[2] || '');
|
||||||
if (inferred !== null) {
|
if (inferred !== null) {
|
||||||
@@ -396,45 +494,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
if (fallbackMatch) {
|
if (fallbackMatch) {
|
||||||
const port = parseInt(fallbackMatch[1], 10);
|
const port = parseInt(fallbackMatch[1], 10);
|
||||||
if (Number.isFinite(port) && port > 0) {
|
if (Number.isFinite(port) && port > 0) {
|
||||||
|
if (detectedPort !== null && detectedPort !== port) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDetectedPort(port);
|
setDetectedPort(port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForPort(timeoutMs: number): Promise<number> {
|
|
||||||
if (detectedPort !== null) {
|
|
||||||
return detectedPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const onPortDetected = (port: number) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(port);
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
portWaiters = portWaiters.filter(cb => cb !== onPortDetected);
|
|
||||||
reject(new Error('Timed out waiting for OpenCode port detection'));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
portWaiters.push(onPortDetected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForReady(apiUrl: string, timeoutMs: number): Promise<boolean> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
// Use quick health check during startup for faster response
|
|
||||||
if (await checkHealth(apiUrl, true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, READY_CHECK_INTERVAL_MS));
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getApiUrl(): string | null {
|
function getApiUrl(): string | null {
|
||||||
if (useConfiguredUrl && configuredApiUrl) {
|
if (useConfiguredUrl && configuredApiUrl) {
|
||||||
return configuredApiUrl.replace(/\/+$/, '');
|
return configuredApiUrl.replace(/\/+$/, '');
|
||||||
@@ -483,7 +550,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
// If user configured an external API URL, do NOT start a local CLI instance.
|
// If user configured an external API URL, do NOT start a local CLI instance.
|
||||||
if (useConfiguredUrl && configuredApiUrl) {
|
if (useConfiguredUrl && configuredApiUrl) {
|
||||||
setStatus('connecting');
|
setStatus('connecting');
|
||||||
const healthy = await checkHealth(configuredApiUrl);
|
const healthy = await checkReady(configuredApiUrl, workingDirectory, false);
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
startHealthCheck();
|
startHealthCheck();
|
||||||
@@ -495,7 +562,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
|
|
||||||
// Check for existing running instance (only if port is known)
|
// Check for existing running instance (only if port is known)
|
||||||
const currentUrl = getApiUrl();
|
const currentUrl = getApiUrl();
|
||||||
if (currentUrl && await checkHealth(currentUrl)) {
|
if (currentUrl && await checkReady(currentUrl, workingDirectory, false)) {
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
startHealthCheck();
|
startHealthCheck();
|
||||||
return;
|
return;
|
||||||
@@ -524,22 +591,29 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
|
|
||||||
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||||
|
|
||||||
// Use port 0 for dynamic assignment unless user configured a specific port
|
|
||||||
const portArg = configuredPort !== null ? configuredPort.toString() : '0';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const portToUse = await (async () => {
|
||||||
|
if (await isTcpPortAvailable(DEFAULT_OPENCODE_PORT)) {
|
||||||
|
return DEFAULT_OPENCODE_PORT;
|
||||||
|
}
|
||||||
|
return await getEphemeralPort();
|
||||||
|
})();
|
||||||
|
|
||||||
const augmentedEnv = {
|
const augmentedEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
PATH: buildAugmentedPath(),
|
PATH: buildAugmentedPath(),
|
||||||
};
|
};
|
||||||
|
|
||||||
childProcess = spawn(cliPath!, ['serve', '--port', portArg], {
|
childProcess = spawn(cliPath!, ['serve', '--port', portToUse.toString()], {
|
||||||
cwd: spawnCwd,
|
cwd: spawnCwd,
|
||||||
env: augmentedEnv,
|
env: augmentedEnv,
|
||||||
detached: false,
|
detached: false,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// We picked the port explicitly, so we don't need to wait for log-based detection.
|
||||||
|
setDetectedPort(portToUse);
|
||||||
|
|
||||||
childProcess.stdout?.on('data', (data) => {
|
childProcess.stdout?.on('data', (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
detectPortFromOutput(text);
|
detectPortFromOutput(text);
|
||||||
@@ -566,34 +640,13 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
lastExitCode = typeof code === 'number' ? code : null;
|
lastExitCode = typeof code === 'number' ? code : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for port detection (port comes from stdout/stderr)
|
const ready = await waitForOpenCodeReadyAtPort(portToUse, READY_CHECK_TIMEOUT_MS);
|
||||||
try {
|
|
||||||
await waitForPort(PORT_DETECTION_TIMEOUT_MS);
|
|
||||||
} catch {
|
|
||||||
setStatus('error', 'OpenCode did not report port in time');
|
|
||||||
await stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now wait for API to be ready
|
|
||||||
const detected = detectedPort;
|
|
||||||
if (detected !== null && !apiPrefixDetected) {
|
|
||||||
await detectApiPrefix(detected);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiUrl = getApiUrl();
|
|
||||||
if (!apiUrl) {
|
|
||||||
setStatus('error', 'Failed to determine OpenCode API URL');
|
|
||||||
await stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = await waitForReady(apiUrl, READY_CHECK_TIMEOUT_MS);
|
|
||||||
if (ready) {
|
if (ready) {
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
startHealthCheck();
|
startHealthCheck();
|
||||||
} else {
|
} else {
|
||||||
setStatus('error', 'OpenCode API did not become ready in time');
|
setStatus('error', 'OpenCode API did not become ready in time');
|
||||||
|
await stop();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -635,6 +688,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
if (target === workingDirectory) {
|
if (target === workingDirectory) {
|
||||||
return { success: true, restarted: false, path: target };
|
return { success: true, restarted: false, path: target };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track requested directory for UI + path resolution.
|
||||||
|
// OpenCode requests should use the `directory` parameter instead of relying on process cwd.
|
||||||
workingDirectory = target;
|
workingDirectory = target;
|
||||||
|
|
||||||
// When pointing at an external API URL, avoid restarting a local CLI process.
|
// When pointing at an external API URL, avoid restarting a local CLI process.
|
||||||
@@ -642,8 +698,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
return { success: true, restarted: false, path: target };
|
return { success: true, restarted: false, path: target };
|
||||||
}
|
}
|
||||||
|
|
||||||
await restart();
|
return { success: true, restarted: false, path: target };
|
||||||
return { success: true, restarted: true, path: target };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -172,9 +172,37 @@ const writePromptFile = (filePath: string, content: string) => {
|
|||||||
fs.writeFileSync(filePath, content, 'utf8');
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all possible project config paths in priority order
|
||||||
|
* Priority: root > .opencode/, json > jsonc
|
||||||
|
*/
|
||||||
|
const getProjectConfigCandidates = (workingDirectory?: string): string[] => {
|
||||||
|
if (!workingDirectory) return [];
|
||||||
|
return [
|
||||||
|
path.join(workingDirectory, 'opencode.json'),
|
||||||
|
path.join(workingDirectory, 'opencode.jsonc'),
|
||||||
|
path.join(workingDirectory, '.opencode', 'opencode.json'),
|
||||||
|
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find existing project config file or return default path for new config
|
||||||
|
*/
|
||||||
const getProjectConfigPath = (workingDirectory?: string): string | null => {
|
const getProjectConfigPath = (workingDirectory?: string): string | null => {
|
||||||
if (!workingDirectory) return null;
|
if (!workingDirectory) return null;
|
||||||
return path.join(workingDirectory, 'opencode.json');
|
|
||||||
|
const candidates = getProjectConfigCandidates(workingDirectory);
|
||||||
|
|
||||||
|
// Return first existing config file
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to root opencode.json for new configs
|
||||||
|
return candidates[0] || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getConfigPaths = (workingDirectory?: string) => ({
|
const getConfigPaths = (workingDirectory?: string) => ({
|
||||||
@@ -289,7 +317,14 @@ const parseMdFile = (filePath: string): { frontmatter: Record<string, unknown>;
|
|||||||
const content = fs.readFileSync(filePath, 'utf8');
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||||
if (!match) return { frontmatter: {}, body: content.trim() };
|
if (!match) return { frontmatter: {}, body: content.trim() };
|
||||||
return { frontmatter: (yaml.parse(match[1]) || {}) as Record<string, unknown>, body: (match[2] || '').trim() };
|
let frontmatter: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
frontmatter = (yaml.parse(match[1]) || {}) as Record<string, unknown>;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[OpenChamber][VSCode] Failed to parse frontmatter for ${filePath}, treating as empty:`, error);
|
||||||
|
frontmatter = {};
|
||||||
|
}
|
||||||
|
return { frontmatter, body: (match[2] || '').trim() };
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, body: string) => {
|
const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, body: string) => {
|
||||||
@@ -541,10 +576,11 @@ export const getCommandSources = (commandName: string, workingDirectory?: string
|
|||||||
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
||||||
const commandSection = jsonSource.section as Record<string, unknown> | undefined;
|
const commandSection = jsonSource.section as Record<string, unknown> | undefined;
|
||||||
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
||||||
|
const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER;
|
||||||
|
|
||||||
const sources: ConfigSources = {
|
const sources: ConfigSources = {
|
||||||
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
|
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
|
||||||
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, fields: [] },
|
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, scope: jsonSource.exists ? jsonScope : null, fields: [] },
|
||||||
projectMd: { exists: projectExists, path: projectPath },
|
projectMd: { exists: projectExists, path: projectPath },
|
||||||
userMd: { exists: userExists, path: userPath }
|
userMd: { exists: userExists, path: userPath }
|
||||||
};
|
};
|
||||||
@@ -1141,4 +1177,3 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
|
|||||||
throw new Error(`Skill "${skillName}" not found`);
|
throw new Error(`Skill "${skillName}" not found`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
|||||||
define: {
|
define: {
|
||||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||||
'global': 'globalThis',
|
'global': 'globalThis',
|
||||||
|
'__OPENCHAMBER_WEBVIEW_BUILD_TIME__': JSON.stringify(new Date().toISOString()),
|
||||||
},
|
},
|
||||||
envPrefix: ['VITE_'],
|
envPrefix: ['VITE_'],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '../../../ui/src/lib/api/types';
|
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
// Use same endpoints as web - fetch interceptor handles URL rewriting
|
// Use same endpoints as web - fetch interceptor handles URL rewriting
|
||||||
const SETTINGS_ENDPOINT = '/api/config/settings';
|
const SETTINGS_ENDPOINT = '/api/config/settings';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { VSCodeAPI } from '../../../ui/src/lib/api/types';
|
import type { VSCodeAPI } from '@openchamber/ui/lib/api/types';
|
||||||
import { executeVSCodeCommand } from './bridge';
|
import { executeVSCodeCommand } from './bridge';
|
||||||
|
|
||||||
export const createVSCodeActionsAPI = (): VSCodeAPI => ({
|
export const createVSCodeActionsAPI = (): VSCodeAPI => ({
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { createVSCodeAPIs } from './api';
|
import { createVSCodeAPIs } from './api';
|
||||||
import { onCommand, onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
|
import { onCommand, onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
|
||||||
import type { RuntimeAPIs } from '../../ui/src/lib/api/types';
|
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||||
import {
|
import {
|
||||||
buildVSCodeThemeFromPalette,
|
buildVSCodeThemeFromPalette,
|
||||||
readVSCodeThemePalette,
|
readVSCodeThemePalette,
|
||||||
type VSCodeThemeKind,
|
type VSCodeThemeKind,
|
||||||
type VSCodeThemePayload,
|
type VSCodeThemePayload,
|
||||||
} from '../../ui/src/lib/theme/vscode/adapter';
|
} from '@openchamber/ui/lib/theme/vscode/adapter';
|
||||||
|
|
||||||
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||||
type PanelType = 'chat' | 'agentManager';
|
type PanelType = 'chat' | 'agentManager';
|
||||||
|
|
||||||
|
declare const __OPENCHAMBER_WEBVIEW_BUILD_TIME__: string;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||||
@@ -31,7 +33,15 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[OpenChamber] VS Code webview starting...');
|
console.log('[OpenChamber] VS Code webview starting...');
|
||||||
|
console.log('[OpenChamber] VS Code webview build:', __OPENCHAMBER_WEBVIEW_BUILD_TIME__);
|
||||||
console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__);
|
console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__);
|
||||||
|
try {
|
||||||
|
if (window.localStorage.getItem('openchamber_stream_debug') === '1') {
|
||||||
|
console.log('[OpenChamber] Debug: openchamber_stream_debug=1');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
|
window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
|
||||||
|
|
||||||
@@ -238,9 +248,19 @@ onThemeChange((payload) => {
|
|||||||
|
|
||||||
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
|
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
|
||||||
if (workspaceFolder) {
|
if (workspaceFolder) {
|
||||||
window.__OPENCHAMBER_HOME__ = workspaceFolder;
|
const normalizeWorkspacePath = (value: string) => {
|
||||||
|
const normalized = value.replace(/\\/g, '/');
|
||||||
|
if (normalized === '/') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedWorkspaceFolder = normalizeWorkspacePath(workspaceFolder);
|
||||||
|
window.__OPENCHAMBER_HOME__ = normalizedWorkspaceFolder;
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem('lastDirectory', workspaceFolder);
|
window.localStorage.setItem('lastDirectory', normalizedWorkspaceFolder);
|
||||||
|
window.localStorage.setItem('homeDirectory', normalizedWorkspaceFolder);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to persist workspace folder', error);
|
console.warn('Failed to persist workspace folder', error);
|
||||||
}
|
}
|
||||||
@@ -370,8 +390,29 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
|||||||
const name = decodeURIComponent(encodedName);
|
const name = decodeURIComponent(encodedName);
|
||||||
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
||||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||||
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
||||||
|
const headerDirectory = (() => {
|
||||||
|
const headers = init?.headers;
|
||||||
|
if (!headers) return undefined;
|
||||||
|
if (headers instanceof Headers) {
|
||||||
|
return headers.get('x-opencode-directory') || undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(headers)) {
|
||||||
|
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
|
||||||
|
return found?.[1] || undefined;
|
||||||
|
}
|
||||||
|
if (typeof headers === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
const directory = queryDirectory || headerDirectory;
|
||||||
try {
|
try {
|
||||||
const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body });
|
const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body, directory });
|
||||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -384,8 +425,29 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
|||||||
const name = decodeURIComponent(encodedName);
|
const name = decodeURIComponent(encodedName);
|
||||||
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
||||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||||
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
||||||
|
const headerDirectory = (() => {
|
||||||
|
const headers = init?.headers;
|
||||||
|
if (!headers) return undefined;
|
||||||
|
if (headers instanceof Headers) {
|
||||||
|
return headers.get('x-opencode-directory') || undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(headers)) {
|
||||||
|
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
|
||||||
|
return found?.[1] || undefined;
|
||||||
|
}
|
||||||
|
if (typeof headers === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
const directory = queryDirectory || headerDirectory;
|
||||||
try {
|
try {
|
||||||
const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body });
|
const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body, directory });
|
||||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -671,7 +733,7 @@ onCommand('addToContext', (payload) => {
|
|||||||
const { text } = payload as { text: string };
|
const { text } = payload as { text: string };
|
||||||
|
|
||||||
// Import the store dynamically to avoid circular dependencies
|
// Import the store dynamically to avoid circular dependencies
|
||||||
import('../../ui/src/stores/useSessionStore').then(({ useSessionStore }) => {
|
import('@/stores/useSessionStore').then(({ useSessionStore }) => {
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
const currentText = store.pendingInputText || '';
|
const currentText = store.pendingInputText || '';
|
||||||
// Append to existing text with double newline separator
|
// Append to existing text with double newline separator
|
||||||
@@ -685,8 +747,8 @@ onCommand('createSessionWithPrompt', (payload) => {
|
|||||||
const { prompt } = payload as { prompt: string };
|
const { prompt } = payload as { prompt: string };
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
import('../../ui/src/stores/useSessionStore'),
|
import('@/stores/useSessionStore'),
|
||||||
import('../../ui/src/stores/useConfigStore'),
|
import('@/stores/useConfigStore'),
|
||||||
]).then(([{ useSessionStore }, { useConfigStore }]) => {
|
]).then(([{ useSessionStore }, { useConfigStore }]) => {
|
||||||
const sessionStore = useSessionStore.getState();
|
const sessionStore = useSessionStore.getState();
|
||||||
const configStore = useConfigStore.getState();
|
const configStore = useConfigStore.getState();
|
||||||
@@ -719,7 +781,7 @@ onCommand('createSessionWithPrompt', (payload) => {
|
|||||||
|
|
||||||
// Listen for newSession command from extension title bar button
|
// Listen for newSession command from extension title bar button
|
||||||
onCommand('newSession', () => {
|
onCommand('newSession', () => {
|
||||||
import('../../ui/src/stores/useSessionStore').then(({ useSessionStore }) => {
|
import('@/stores/useSessionStore').then(({ useSessionStore }) => {
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.openNewSessionDraft();
|
store.openNewSessionDraft();
|
||||||
});
|
});
|
||||||
@@ -734,7 +796,7 @@ onCommand('showSettings', () => {
|
|||||||
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } }));
|
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } }));
|
||||||
});
|
});
|
||||||
|
|
||||||
import('../../ui/src/main')
|
import('@/main')
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await waitForUiMount();
|
await waitForUiMount();
|
||||||
uiMounted = true;
|
uiMounted = true;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@opencode-ai/sdk": "^1.0.209",
|
"@opencode-ai/sdk": "^1.1.1",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
+423
-85
@@ -6,6 +6,7 @@ import fs from 'fs';
|
|||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { createUiAuth } from './lib/ui-auth.js';
|
import { createUiAuth } from './lib/ui-auth.js';
|
||||||
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
||||||
|
|
||||||
@@ -315,6 +316,76 @@ const writeSettingsToDisk = async (settings) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveDirectoryCandidate = (value) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = normalizeDirectoryPath(trimmed);
|
||||||
|
return path.resolve(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateDirectoryPath = async (candidate) => {
|
||||||
|
const resolved = resolveDirectoryCandidate(candidate);
|
||||||
|
if (!resolved) {
|
||||||
|
return { ok: false, error: 'Directory parameter is required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stats = await fsPromises.stat(resolved);
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return { ok: false, error: 'Specified path is not a directory' };
|
||||||
|
}
|
||||||
|
return { ok: true, directory: resolved };
|
||||||
|
} catch (error) {
|
||||||
|
const err = error;
|
||||||
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||||
|
return { ok: false, error: 'Directory not found' };
|
||||||
|
}
|
||||||
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
||||||
|
return { ok: false, error: 'Access to directory denied' };
|
||||||
|
}
|
||||||
|
return { ok: false, error: 'Failed to validate directory' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveProjectDirectory = async (req) => {
|
||||||
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||||
|
const queryDirectory = Array.isArray(req.query?.directory)
|
||||||
|
? req.query.directory[0]
|
||||||
|
: req.query?.directory;
|
||||||
|
const requested = headerDirectory || queryDirectory || null;
|
||||||
|
|
||||||
|
if (requested) {
|
||||||
|
const validated = await validateDirectoryPath(requested);
|
||||||
|
if (!validated.ok) {
|
||||||
|
return { directory: null, error: validated.error };
|
||||||
|
}
|
||||||
|
return { directory: validated.directory, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await readSettingsFromDiskMigrated();
|
||||||
|
const projects = sanitizeProjects(settings.projects) || [];
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return { directory: null, error: 'Directory parameter or active project is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
|
||||||
|
const active = projects.find((project) => project.id === activeId) || projects[0];
|
||||||
|
if (!active || !active.path) {
|
||||||
|
return { directory: null, error: 'Directory parameter or active project is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = await validateDirectoryPath(active.path);
|
||||||
|
if (!validated.ok) {
|
||||||
|
return { directory: null, error: validated.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { directory: validated.directory, error: null };
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeTypographySizesPartial = (input) => {
|
const sanitizeTypographySizesPartial = (input) => {
|
||||||
if (!input || typeof input !== 'object') {
|
if (!input || typeof input !== 'object') {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -384,6 +455,47 @@ const sanitizeSkillCatalogs = (input) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sanitizeProjects = (input) => {
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const seenIds = new Set();
|
||||||
|
const seenPaths = new Set();
|
||||||
|
|
||||||
|
for (const entry of input) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
|
||||||
|
const candidate = entry;
|
||||||
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||||
|
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
||||||
|
const normalizedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
|
||||||
|
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||||
|
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
|
||||||
|
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
|
||||||
|
? Number(candidate.lastOpenedAt)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!id || !normalizedPath) continue;
|
||||||
|
if (seenIds.has(id)) continue;
|
||||||
|
if (seenPaths.has(normalizedPath)) continue;
|
||||||
|
|
||||||
|
seenIds.add(id);
|
||||||
|
seenPaths.add(normalizedPath);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id,
|
||||||
|
path: normalizedPath,
|
||||||
|
...(label ? { label } : {}),
|
||||||
|
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
||||||
|
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeSettingsUpdate = (payload) => {
|
const sanitizeSettingsUpdate = (payload) => {
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (!payload || typeof payload !== 'object') {
|
||||||
return {};
|
return {};
|
||||||
@@ -413,6 +525,15 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
||||||
result.homeDirectory = candidate.homeDirectory;
|
result.homeDirectory = candidate.homeDirectory;
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(candidate.projects)) {
|
||||||
|
const projects = sanitizeProjects(candidate.projects);
|
||||||
|
if (projects) {
|
||||||
|
result.projects = projects;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
|
||||||
|
result.activeProjectId = candidate.activeProjectId;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(candidate.approvedDirectories)) {
|
if (Array.isArray(candidate.approvedDirectories)) {
|
||||||
result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories);
|
result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories);
|
||||||
@@ -481,6 +602,16 @@ const mergePersistedSettings = (current, changes) => {
|
|||||||
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
|
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
|
||||||
additionalApproved.push(changes.homeDirectory);
|
additionalApproved.push(changes.homeDirectory);
|
||||||
}
|
}
|
||||||
|
const projectEntries = Array.isArray(changes.projects)
|
||||||
|
? changes.projects
|
||||||
|
: Array.isArray(current.projects)
|
||||||
|
? current.projects
|
||||||
|
: [];
|
||||||
|
projectEntries.forEach((project) => {
|
||||||
|
if (project && typeof project.path === 'string' && project.path.length > 0) {
|
||||||
|
additionalApproved.push(project.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
const approvedSource = [...baseApproved, ...additionalApproved];
|
const approvedSource = [...baseApproved, ...additionalApproved];
|
||||||
|
|
||||||
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
|
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
|
||||||
@@ -535,10 +666,124 @@ const formatSettingsResponse = (settings) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateProjectEntries = async (projects) => {
|
||||||
|
if (!Array.isArray(projects)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!project || typeof project.path !== 'string' || project.path.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stats = await fsPromises.stat(project.path);
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push(project);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error;
|
||||||
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrateSettingsFromLegacyLastDirectory = async (current) => {
|
||||||
|
const settings = current && typeof current === 'object' ? current : {};
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const sanitizedProjects = sanitizeProjects(settings.projects) || [];
|
||||||
|
let nextProjects = sanitizedProjects;
|
||||||
|
let nextActiveProjectId =
|
||||||
|
typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (nextProjects.length === 0) {
|
||||||
|
const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
|
||||||
|
const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
|
||||||
|
|
||||||
|
if (candidate) {
|
||||||
|
try {
|
||||||
|
const stats = await fsPromises.stat(candidate);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
nextProjects = [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
path: candidate,
|
||||||
|
addedAt: now,
|
||||||
|
lastOpenedAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
nextActiveProjectId = id;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid lastDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextProjects.length > 0) {
|
||||||
|
const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
|
||||||
|
if (!active) {
|
||||||
|
nextActiveProjectId = nextProjects[0].id;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (nextActiveProjectId) {
|
||||||
|
nextActiveProjectId = undefined;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return { settings, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = mergePersistedSettings(settings, {
|
||||||
|
...settings,
|
||||||
|
projects: nextProjects,
|
||||||
|
...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { settings: merged, changed: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSettingsFromDiskMigrated = async () => {
|
||||||
|
const current = await readSettingsFromDisk();
|
||||||
|
const { settings, changed } = await migrateSettingsFromLegacyLastDirectory(current);
|
||||||
|
if (changed) {
|
||||||
|
await writeSettingsToDisk(settings);
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
};
|
||||||
|
|
||||||
const persistSettings = async (changes) => {
|
const persistSettings = async (changes) => {
|
||||||
const current = await readSettingsFromDisk();
|
const current = await readSettingsFromDisk();
|
||||||
const sanitized = sanitizeSettingsUpdate(changes);
|
const sanitized = sanitizeSettingsUpdate(changes);
|
||||||
const next = mergePersistedSettings(current, sanitized);
|
let next = mergePersistedSettings(current, sanitized);
|
||||||
|
|
||||||
|
if (Array.isArray(next.projects)) {
|
||||||
|
const validated = await validateProjectEntries(next.projects);
|
||||||
|
next = { ...next, projects: validated };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(next.projects) && next.projects.length > 0) {
|
||||||
|
const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
|
||||||
|
const active = next.projects.find((project) => project.id === activeId) || null;
|
||||||
|
if (!active) {
|
||||||
|
next = { ...next, activeProjectId: next.projects[0].id };
|
||||||
|
}
|
||||||
|
} else if (next.activeProjectId) {
|
||||||
|
next = { ...next, activeProjectId: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
await writeSettingsToDisk(next);
|
await writeSettingsToDisk(next);
|
||||||
return formatSettingsResponse(next);
|
return formatSettingsResponse(next);
|
||||||
};
|
};
|
||||||
@@ -551,7 +796,7 @@ const getHmrState = () => {
|
|||||||
globalThis[HMR_STATE_KEY] = {
|
globalThis[HMR_STATE_KEY] = {
|
||||||
openCodeProcess: null,
|
openCodeProcess: null,
|
||||||
openCodePort: null,
|
openCodePort: null,
|
||||||
openCodeWorkingDirectory: process.cwd(),
|
openCodeWorkingDirectory: os.homedir(),
|
||||||
isShuttingDown: false,
|
isShuttingDown: false,
|
||||||
signalsAttached: false,
|
signalsAttached: false,
|
||||||
};
|
};
|
||||||
@@ -2152,6 +2397,10 @@ async function main(options = {}) {
|
|||||||
res.flushHeaders();
|
res.flushHeaders();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const reader = upstream.body.getReader();
|
const reader = upstream.body.getReader();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
@@ -2194,6 +2443,7 @@ async function main(options = {}) {
|
|||||||
console.warn('SSE proxy stream error:', error);
|
console.warn('SSE proxy stream error:', error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
cleanup();
|
cleanup();
|
||||||
try {
|
try {
|
||||||
res.end();
|
res.end();
|
||||||
@@ -2220,11 +2470,13 @@ async function main(options = {}) {
|
|||||||
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||||
const directoryParam = Array.isArray(req.query.directory)
|
const directoryParam = Array.isArray(req.query.directory)
|
||||||
? req.query.directory[0]
|
? req.query.directory[0]
|
||||||
: req.query.directory;
|
: req.query.directory;
|
||||||
if (typeof directoryParam === 'string' && directoryParam.trim().length > 0) {
|
const resolvedDirectory = headerDirectory || directoryParam || null;
|
||||||
targetUrl.searchParams.set('directory', directoryParam.trim());
|
if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) {
|
||||||
|
targetUrl.searchParams.set('directory', resolvedDirectory.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -2271,6 +2523,10 @@ async function main(options = {}) {
|
|||||||
res.flushHeaders();
|
res.flushHeaders();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const reader = upstream.body.getReader();
|
const reader = upstream.body.getReader();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
@@ -2313,6 +2569,7 @@ async function main(options = {}) {
|
|||||||
console.warn('SSE proxy stream error:', error);
|
console.warn('SSE proxy stream error:', error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
cleanup();
|
cleanup();
|
||||||
try {
|
try {
|
||||||
res.end();
|
res.end();
|
||||||
@@ -2324,7 +2581,7 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
app.get('/api/config/settings', async (_req, res) => {
|
app.get('/api/config/settings', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await readSettingsFromDisk();
|
const settings = await readSettingsFromDiskMigrated();
|
||||||
res.json(formatSettingsResponse(settings));
|
res.json(formatSettingsResponse(settings));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
@@ -2345,6 +2602,7 @@ async function main(options = {}) {
|
|||||||
const {
|
const {
|
||||||
getAgentSources,
|
getAgentSources,
|
||||||
getAgentScope,
|
getAgentScope,
|
||||||
|
getAgentConfig,
|
||||||
createAgent,
|
createAgent,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
@@ -2357,16 +2615,23 @@ async function main(options = {}) {
|
|||||||
COMMAND_SCOPE
|
COMMAND_SCOPE
|
||||||
} = await import('./lib/opencode-config.js');
|
} = await import('./lib/opencode-config.js');
|
||||||
|
|
||||||
app.get('/api/config/agents/:name', (req, res) => {
|
app.get('/api/config/agents/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const agentName = req.params.name;
|
const agentName = req.params.name;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
const sources = getAgentSources(agentName, workingDirectory);
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
const sources = getAgentSources(agentName, directory);
|
||||||
|
|
||||||
|
const scope = sources.md.exists
|
||||||
|
? sources.md.scope
|
||||||
|
: (sources.json.exists ? sources.json.scope : null);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
name: agentName,
|
name: agentName,
|
||||||
sources: sources,
|
sources: sources,
|
||||||
scope: sources.md.scope,
|
scope,
|
||||||
isBuiltIn: !sources.md.exists && !sources.json.exists
|
isBuiltIn: !sources.md.exists && !sources.json.exists
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2375,17 +2640,36 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/config/agents/:name/config', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const agentName = req.params.name;
|
||||||
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const configInfo = getAgentConfig(agentName, directory);
|
||||||
|
res.json(configInfo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get agent config:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get agent configuration' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/config/agents/:name', async (req, res) => {
|
app.post('/api/config/agents/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const agentName = req.params.name;
|
const agentName = req.params.name;
|
||||||
const { scope, ...config } = req.body;
|
const { scope, ...config } = req.body;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Server] Creating agent:', agentName);
|
console.log('[Server] Creating agent:', agentName);
|
||||||
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
||||||
console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||||
|
|
||||||
createAgent(agentName, config, workingDirectory, scope);
|
createAgent(agentName, config, directory, scope);
|
||||||
await refreshOpenCodeAfterConfigChange('agent creation', {
|
await refreshOpenCodeAfterConfigChange('agent creation', {
|
||||||
agentName
|
agentName
|
||||||
});
|
});
|
||||||
@@ -2406,13 +2690,16 @@ async function main(options = {}) {
|
|||||||
try {
|
try {
|
||||||
const agentName = req.params.name;
|
const agentName = req.params.name;
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Server] Updating agent: ${agentName}`);
|
console.log(`[Server] Updating agent: ${agentName}`);
|
||||||
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
||||||
console.log('[Server] Working directory:', workingDirectory);
|
console.log('[Server] Working directory:', directory);
|
||||||
|
|
||||||
updateAgent(agentName, updates, workingDirectory);
|
updateAgent(agentName, updates, directory);
|
||||||
await refreshOpenCodeAfterConfigChange('agent update');
|
await refreshOpenCodeAfterConfigChange('agent update');
|
||||||
|
|
||||||
console.log(`[Server] Agent ${agentName} updated successfully`);
|
console.log(`[Server] Agent ${agentName} updated successfully`);
|
||||||
@@ -2433,9 +2720,12 @@ async function main(options = {}) {
|
|||||||
app.delete('/api/config/agents/:name', async (req, res) => {
|
app.delete('/api/config/agents/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const agentName = req.params.name;
|
const agentName = req.params.name;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
deleteAgent(agentName, workingDirectory);
|
deleteAgent(agentName, directory);
|
||||||
await refreshOpenCodeAfterConfigChange('agent deletion');
|
await refreshOpenCodeAfterConfigChange('agent deletion');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -2450,16 +2740,23 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/config/commands/:name', (req, res) => {
|
app.get('/api/config/commands/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const commandName = req.params.name;
|
const commandName = req.params.name;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
const sources = getCommandSources(commandName, workingDirectory);
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
const sources = getCommandSources(commandName, directory);
|
||||||
|
|
||||||
|
const scope = sources.md.exists
|
||||||
|
? sources.md.scope
|
||||||
|
: (sources.json.exists ? sources.json.scope : null);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
name: commandName,
|
name: commandName,
|
||||||
sources: sources,
|
sources: sources,
|
||||||
scope: sources.md.scope,
|
scope,
|
||||||
isBuiltIn: !sources.md.exists && !sources.json.exists
|
isBuiltIn: !sources.md.exists && !sources.json.exists
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2472,13 +2769,16 @@ async function main(options = {}) {
|
|||||||
try {
|
try {
|
||||||
const commandName = req.params.name;
|
const commandName = req.params.name;
|
||||||
const { scope, ...config } = req.body;
|
const { scope, ...config } = req.body;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Server] Creating command:', commandName);
|
console.log('[Server] Creating command:', commandName);
|
||||||
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
||||||
console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||||
|
|
||||||
createCommand(commandName, config, workingDirectory, scope);
|
createCommand(commandName, config, directory, scope);
|
||||||
await refreshOpenCodeAfterConfigChange('command creation', {
|
await refreshOpenCodeAfterConfigChange('command creation', {
|
||||||
commandName
|
commandName
|
||||||
});
|
});
|
||||||
@@ -2499,13 +2799,16 @@ async function main(options = {}) {
|
|||||||
try {
|
try {
|
||||||
const commandName = req.params.name;
|
const commandName = req.params.name;
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Server] Updating command: ${commandName}`);
|
console.log(`[Server] Updating command: ${commandName}`);
|
||||||
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
||||||
console.log('[Server] Working directory:', workingDirectory);
|
console.log('[Server] Working directory:', directory);
|
||||||
|
|
||||||
updateCommand(commandName, updates, workingDirectory);
|
updateCommand(commandName, updates, directory);
|
||||||
await refreshOpenCodeAfterConfigChange('command update');
|
await refreshOpenCodeAfterConfigChange('command update');
|
||||||
|
|
||||||
console.log(`[Server] Command ${commandName} updated successfully`);
|
console.log(`[Server] Command ${commandName} updated successfully`);
|
||||||
@@ -2526,9 +2829,12 @@ async function main(options = {}) {
|
|||||||
app.delete('/api/config/commands/:name', async (req, res) => {
|
app.delete('/api/config/commands/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const commandName = req.params.name;
|
const commandName = req.params.name;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
deleteCommand(commandName, workingDirectory);
|
deleteCommand(commandName, directory);
|
||||||
await refreshOpenCodeAfterConfigChange('command deletion');
|
await refreshOpenCodeAfterConfigChange('command deletion');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -2559,14 +2865,17 @@ async function main(options = {}) {
|
|||||||
} = await import('./lib/opencode-config.js');
|
} = await import('./lib/opencode-config.js');
|
||||||
|
|
||||||
// List all discovered skills
|
// List all discovered skills
|
||||||
app.get('/api/config/skills', (req, res) => {
|
app.get('/api/config/skills', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
const skills = discoverSkills(workingDirectory);
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
const skills = discoverSkills(directory);
|
||||||
|
|
||||||
// Enrich with full sources info
|
// Enrich with full sources info
|
||||||
const enrichedSkills = skills.map(skill => {
|
const enrichedSkills = skills.map(skill => {
|
||||||
const sources = getSkillSources(skill.name, workingDirectory);
|
const sources = getSkillSources(skill.name, directory);
|
||||||
return {
|
return {
|
||||||
...skill,
|
...skill,
|
||||||
sources
|
sources
|
||||||
@@ -2616,7 +2925,10 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
app.get('/api/config/skills/catalog', async (req, res) => {
|
app.get('/api/config/skills/catalog', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
||||||
|
|
||||||
const curatedSources = getCuratedSkillsSources();
|
const curatedSources = getCuratedSkillsSources();
|
||||||
@@ -2634,7 +2946,7 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
const sources = [...curatedSources, ...customSources];
|
const sources = [...curatedSources, ...customSources];
|
||||||
|
|
||||||
const discovered = discoverSkills(workingDirectory);
|
const discovered = discoverSkills(directory);
|
||||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||||
|
|
||||||
const itemsBySource = {};
|
const itemsBySource = {};
|
||||||
@@ -2738,12 +3050,16 @@ async function main(options = {}) {
|
|||||||
conflictDecisions,
|
conflictDecisions,
|
||||||
} = req.body || {};
|
} = req.body || {};
|
||||||
|
|
||||||
const workingDirectory = req.query.directory;
|
let workingDirectory = null;
|
||||||
if (scope === 'project' && !workingDirectory) {
|
if (scope === 'project') {
|
||||||
return res.status(400).json({
|
const resolved = await resolveProjectDirectory(req);
|
||||||
ok: false,
|
if (!resolved.directory) {
|
||||||
error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' },
|
return res.status(400).json({
|
||||||
});
|
ok: false,
|
||||||
|
error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
workingDirectory = resolved.directory;
|
||||||
}
|
}
|
||||||
const identity = resolveGitIdentity(gitIdentityId);
|
const identity = resolveGitIdentity(gitIdentityId);
|
||||||
|
|
||||||
@@ -2785,11 +3101,14 @@ async function main(options = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get single skill sources
|
// Get single skill sources
|
||||||
app.get('/api/config/skills/:name', (req, res) => {
|
app.get('/api/config/skills/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
const sources = getSkillSources(skillName, workingDirectory);
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
const sources = getSkillSources(skillName, directory);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
name: skillName,
|
name: skillName,
|
||||||
@@ -2805,13 +3124,16 @@ async function main(options = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get skill supporting file content
|
// Get skill supporting file content
|
||||||
app.get('/api/config/skills/:name/files/*filePath', (req, res) => {
|
app.get('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
const sources = getSkillSources(skillName, workingDirectory);
|
const sources = getSkillSources(skillName, directory);
|
||||||
if (!sources.md.exists || !sources.md.dir) {
|
if (!sources.md.exists || !sources.md.dir) {
|
||||||
return res.status(404).json({ error: 'Skill not found' });
|
return res.status(404).json({ error: 'Skill not found' });
|
||||||
}
|
}
|
||||||
@@ -2833,12 +3155,15 @@ async function main(options = {}) {
|
|||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const { scope, ...config } = req.body;
|
const { scope, ...config } = req.body;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Server] Creating skill:', skillName);
|
console.log('[Server] Creating skill:', skillName);
|
||||||
console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||||
|
|
||||||
createSkill(skillName, config, workingDirectory, scope);
|
createSkill(skillName, config, directory, scope);
|
||||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -2857,12 +3182,15 @@ async function main(options = {}) {
|
|||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Server] Updating skill: ${skillName}`);
|
console.log(`[Server] Updating skill: ${skillName}`);
|
||||||
console.log('[Server] Working directory:', workingDirectory);
|
console.log('[Server] Working directory:', directory);
|
||||||
|
|
||||||
updateSkill(skillName, updates, workingDirectory);
|
updateSkill(skillName, updates, directory);
|
||||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -2882,9 +3210,12 @@ async function main(options = {}) {
|
|||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
||||||
const { content } = req.body;
|
const { content } = req.body;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
const sources = getSkillSources(skillName, workingDirectory);
|
const sources = getSkillSources(skillName, directory);
|
||||||
if (!sources.md.exists || !sources.md.dir) {
|
if (!sources.md.exists || !sources.md.dir) {
|
||||||
return res.status(404).json({ error: 'Skill not found' });
|
return res.status(404).json({ error: 'Skill not found' });
|
||||||
}
|
}
|
||||||
@@ -2906,9 +3237,12 @@ async function main(options = {}) {
|
|||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
const sources = getSkillSources(skillName, workingDirectory);
|
const sources = getSkillSources(skillName, directory);
|
||||||
if (!sources.md.exists || !sources.md.dir) {
|
if (!sources.md.exists || !sources.md.dir) {
|
||||||
return res.status(404).json({ error: 'Skill not found' });
|
return res.status(404).json({ error: 'Skill not found' });
|
||||||
}
|
}
|
||||||
@@ -2929,9 +3263,12 @@ async function main(options = {}) {
|
|||||||
app.delete('/api/config/skills/:name', async (req, res) => {
|
app.delete('/api/config/skills/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
const { directory, error } = await resolveProjectDirectory(req);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error });
|
||||||
|
}
|
||||||
|
|
||||||
deleteSkill(skillName, workingDirectory);
|
deleteSkill(skillName, directory);
|
||||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -3677,40 +4014,41 @@ async function main(options = {}) {
|
|||||||
return res.status(400).json({ error: 'Path is required' });
|
return res.status(400).json({ error: 'Path is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPath = path.resolve(normalizeDirectoryPath(requestedPath));
|
const validated = await validateDirectoryPath(requestedPath);
|
||||||
let stats;
|
if (!validated.ok) {
|
||||||
try {
|
return res.status(400).json({ error: validated.error });
|
||||||
stats = await fsPromises.stat(resolvedPath);
|
|
||||||
} catch (error) {
|
|
||||||
const err = error;
|
|
||||||
if (err && typeof err === 'object' && 'code' in err) {
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
return res.status(404).json({ error: 'Directory not found' });
|
|
||||||
}
|
|
||||||
if (err.code === 'EACCES') {
|
|
||||||
return res.status(403).json({ error: 'Access to directory denied' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stats.isDirectory()) {
|
const resolvedPath = validated.directory;
|
||||||
return res.status(400).json({ error: 'Specified path is not a directory' });
|
const currentSettings = await readSettingsFromDisk();
|
||||||
}
|
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
|
||||||
|
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
|
||||||
|
|
||||||
if (openCodeWorkingDirectory === resolvedPath && openCodeProcess && openCodeProcess.exitCode === null) {
|
const nextProjects = existing
|
||||||
return res.json({ success: true, restarted: false, path: resolvedPath });
|
? existingProjects
|
||||||
}
|
: [
|
||||||
|
...existingProjects,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
path: resolvedPath,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
lastOpenedAt: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
openCodeWorkingDirectory = resolvedPath;
|
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
|
||||||
syncToHmrState();
|
|
||||||
|
|
||||||
await refreshOpenCodeAfterConfigChange('directory change');
|
const updated = await persistSettings({
|
||||||
|
projects: nextProjects,
|
||||||
|
activeProjectId,
|
||||||
|
lastDirectory: resolvedPath,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
restarted: true,
|
restarted: false,
|
||||||
path: resolvedPath
|
path: resolvedPath,
|
||||||
|
settings: updated,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update OpenCode working directory:', error);
|
console.error('Failed to update OpenCode working directory:', error);
|
||||||
|
|||||||
@@ -101,22 +101,72 @@ function getAgentWritePath(agentName, workingDirectory, requestedScope) {
|
|||||||
if (existing.path) {
|
if (existing.path) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For new agents or built-in overrides: use requested scope or default to user
|
// For new agents or built-in overrides: use requested scope or default to user
|
||||||
const scope = requestedScope || AGENT_SCOPE.USER;
|
const scope = requestedScope || AGENT_SCOPE.USER;
|
||||||
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
||||||
return {
|
return {
|
||||||
scope: AGENT_SCOPE.PROJECT,
|
scope: AGENT_SCOPE.PROJECT,
|
||||||
path: getProjectAgentPath(workingDirectory, agentName)
|
path: getProjectAgentPath(workingDirectory, agentName)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scope: AGENT_SCOPE.USER,
|
scope: AGENT_SCOPE.USER,
|
||||||
path: getUserAgentPath(agentName)
|
path: getUserAgentPath(agentName)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect where an agent's permission field is currently defined
|
||||||
|
* Priority: project .md > user .md > project JSON > user JSON
|
||||||
|
* Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null }
|
||||||
|
*/
|
||||||
|
function getAgentPermissionSource(agentName, workingDirectory) {
|
||||||
|
// Check project-level .md first
|
||||||
|
if (workingDirectory) {
|
||||||
|
const projectMdPath = getProjectAgentPath(workingDirectory, agentName);
|
||||||
|
if (fs.existsSync(projectMdPath)) {
|
||||||
|
const { frontmatter } = parseMdFile(projectMdPath);
|
||||||
|
if (frontmatter.permission !== undefined) {
|
||||||
|
return { source: 'md', scope: AGENT_SCOPE.PROJECT, path: projectMdPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user-level .md
|
||||||
|
const userMdPath = getUserAgentPath(agentName);
|
||||||
|
if (fs.existsSync(userMdPath)) {
|
||||||
|
const { frontmatter } = parseMdFile(userMdPath);
|
||||||
|
if (frontmatter.permission !== undefined) {
|
||||||
|
return { source: 'md', scope: AGENT_SCOPE.USER, path: userMdPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check JSON layers (project > user)
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
|
||||||
|
// Project opencode.json
|
||||||
|
const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission;
|
||||||
|
if (projectJsonPermission !== undefined && layers.paths.projectPath) {
|
||||||
|
return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// User opencode.json
|
||||||
|
const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission;
|
||||||
|
if (userJsonPermission !== undefined) {
|
||||||
|
return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom config (env var)
|
||||||
|
const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission;
|
||||||
|
if (customJsonPermission !== undefined && layers.paths.customPath) {
|
||||||
|
return { source: 'json', scope: 'custom', path: layers.paths.customPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source: null, scope: null, path: null };
|
||||||
|
}
|
||||||
|
|
||||||
// ============== COMMAND SCOPE HELPERS ==============
|
// ============== COMMAND SCOPE HELPERS ==============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -412,9 +462,125 @@ function writePromptFile(filePath, content) {
|
|||||||
console.log(`Updated prompt file: ${filePath}`);
|
console.log(`Updated prompt file: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all possible project config paths in priority order
|
||||||
|
* Priority: root > .opencode/, json > jsonc
|
||||||
|
*/
|
||||||
|
function getProjectConfigCandidates(workingDirectory) {
|
||||||
|
if (!workingDirectory) return [];
|
||||||
|
return [
|
||||||
|
path.join(workingDirectory, 'opencode.json'),
|
||||||
|
path.join(workingDirectory, 'opencode.jsonc'),
|
||||||
|
path.join(workingDirectory, '.opencode', 'opencode.json'),
|
||||||
|
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find existing project config file or return default path for new config
|
||||||
|
*/
|
||||||
function getProjectConfigPath(workingDirectory) {
|
function getProjectConfigPath(workingDirectory) {
|
||||||
if (!workingDirectory) return null;
|
if (!workingDirectory) return null;
|
||||||
return path.join(workingDirectory, 'opencode.json');
|
|
||||||
|
const candidates = getProjectConfigCandidates(workingDirectory);
|
||||||
|
|
||||||
|
// Return first existing config file
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to root opencode.json for new configs
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge new permission config with existing non-wildcard patterns
|
||||||
|
* Non-wildcard patterns (patterns other than "*") are preserved from existing config
|
||||||
|
* @param {object|string|null} newPermission - New permission config from UI (wildcards only)
|
||||||
|
* @param {object} permissionSource - Result from getAgentPermissionSource
|
||||||
|
* @param {string} agentName - Agent name
|
||||||
|
* @param {string|null} workingDirectory - Working directory
|
||||||
|
* @returns {object|string|null} Merged permission config
|
||||||
|
*/
|
||||||
|
function mergePermissionWithNonWildcards(newPermission, permissionSource, agentName, workingDirectory) {
|
||||||
|
// If no existing permission, return new permission as-is
|
||||||
|
if (!permissionSource.source || !permissionSource.path) {
|
||||||
|
return newPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing permission config
|
||||||
|
let existingPermission = null;
|
||||||
|
if (permissionSource.source === 'md') {
|
||||||
|
const { frontmatter } = parseMdFile(permissionSource.path);
|
||||||
|
existingPermission = frontmatter.permission;
|
||||||
|
} else if (permissionSource.source === 'json') {
|
||||||
|
const config = readConfigFile(permissionSource.path);
|
||||||
|
existingPermission = config?.agent?.[agentName]?.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no existing permission or it's a simple string, return new permission as-is
|
||||||
|
if (!existingPermission || typeof existingPermission === 'string') {
|
||||||
|
return newPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If new permission is null/undefined, return null to clear it
|
||||||
|
if (newPermission == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If new permission is a simple string (e.g., "allow"), return it as-is
|
||||||
|
if (typeof newPermission === 'string') {
|
||||||
|
return newPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract non-wildcard patterns from existing permission
|
||||||
|
const nonWildcardPatterns = {};
|
||||||
|
for (const [permKey, permValue] of Object.entries(existingPermission)) {
|
||||||
|
if (permKey === '*') continue; // Skip global default
|
||||||
|
|
||||||
|
if (typeof permValue === 'object' && permValue !== null && !Array.isArray(permValue)) {
|
||||||
|
// Permission has pattern-based config (e.g., { "npm *": "allow", "*": "ask" })
|
||||||
|
const nonWildcards = {};
|
||||||
|
for (const [pattern, action] of Object.entries(permValue)) {
|
||||||
|
if (pattern !== '*') {
|
||||||
|
nonWildcards[pattern] = action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(nonWildcards).length > 0) {
|
||||||
|
nonWildcardPatterns[permKey] = nonWildcards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Simple string values (e.g., "allow") don't have patterns, skip them
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no non-wildcard patterns to preserve, return new permission as-is
|
||||||
|
if (Object.keys(nonWildcardPatterns).length === 0) {
|
||||||
|
return newPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge non-wildcards into new permission
|
||||||
|
const merged = { ...newPermission };
|
||||||
|
for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) {
|
||||||
|
const newValue = merged[permKey];
|
||||||
|
if (typeof newValue === 'string') {
|
||||||
|
// Convert string to object with wildcard + preserved patterns
|
||||||
|
merged[permKey] = { '*': newValue, ...patterns };
|
||||||
|
} else if (typeof newValue === 'object' && newValue !== null) {
|
||||||
|
// Merge patterns, new wildcards take precedence
|
||||||
|
merged[permKey] = { ...patterns, ...newValue };
|
||||||
|
} else {
|
||||||
|
// Permission not in new config - preserve existing patterns with their wildcard if it existed
|
||||||
|
const existingValue = existingPermission[permKey];
|
||||||
|
if (typeof existingValue === 'object' && existingValue !== null) {
|
||||||
|
const wildcard = existingValue['*'];
|
||||||
|
merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigPaths(workingDirectory) {
|
function getConfigPaths(workingDirectory) {
|
||||||
@@ -539,22 +705,23 @@ function getJsonWriteTarget(layers, preferredScope) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseMdFile(filePath) {
|
function parseMdFile(filePath) {
|
||||||
try {
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { frontmatter: {}, body: content.trim() };
|
return { frontmatter: {}, body: content.trim() };
|
||||||
}
|
|
||||||
|
|
||||||
const frontmatter = yaml.parse(match[1]) || {};
|
|
||||||
const body = match[2].trim();
|
|
||||||
|
|
||||||
return { frontmatter, body };
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to parse markdown file ${filePath}:`, error);
|
|
||||||
throw new Error('Failed to parse agent markdown file');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let frontmatter = {};
|
||||||
|
try {
|
||||||
|
frontmatter = yaml.parse(match[1]) || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
|
||||||
|
frontmatter = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = match[2].trim();
|
||||||
|
return { frontmatter, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeMdFile(filePath, frontmatter, body) {
|
function writeMdFile(filePath, frontmatter, body) {
|
||||||
@@ -606,12 +773,6 @@ function getAgentSources(agentName, workingDirectory) {
|
|||||||
scope: jsonSource.exists ? jsonScope : null,
|
scope: jsonSource.exists ? jsonScope : null,
|
||||||
fields: []
|
fields: []
|
||||||
},
|
},
|
||||||
json: {
|
|
||||||
exists: jsonSource.exists,
|
|
||||||
path: jsonPath,
|
|
||||||
scope: jsonSource.exists ? jsonScope : null,
|
|
||||||
fields: []
|
|
||||||
},
|
|
||||||
// Additional info about both levels
|
// Additional info about both levels
|
||||||
projectMd: {
|
projectMd: {
|
||||||
exists: projectExists,
|
exists: projectExists,
|
||||||
@@ -638,6 +799,48 @@ function getAgentSources(agentName, workingDirectory) {
|
|||||||
return sources;
|
return sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAgentConfig(agentName, workingDirectory) {
|
||||||
|
// Prefer markdown agents (project > user)
|
||||||
|
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
||||||
|
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||||
|
|
||||||
|
const userPath = getUserAgentPath(agentName);
|
||||||
|
const userExists = fs.existsSync(userPath);
|
||||||
|
|
||||||
|
if (projectExists || userExists) {
|
||||||
|
const mdPath = projectExists ? projectPath : userPath;
|
||||||
|
const { frontmatter, body } = parseMdFile(mdPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'md',
|
||||||
|
scope: projectExists ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER,
|
||||||
|
config: {
|
||||||
|
...frontmatter,
|
||||||
|
...(typeof body === 'string' && body.length > 0 ? { prompt: body } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then fall back to opencode.json (highest-precedence entry)
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
||||||
|
|
||||||
|
if (jsonSource.exists && jsonSource.section) {
|
||||||
|
const scope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
||||||
|
return {
|
||||||
|
source: 'json',
|
||||||
|
scope,
|
||||||
|
config: { ...jsonSource.section },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'none',
|
||||||
|
scope: null,
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createAgent(agentName, config, workingDirectory, scope) {
|
function createAgent(agentName, config, workingDirectory, scope) {
|
||||||
ensureDirs();
|
ensureDirs();
|
||||||
|
|
||||||
@@ -693,7 +896,7 @@ function updateAgent(agentName, updates, workingDirectory) {
|
|||||||
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
|
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
|
||||||
const jsonTarget = jsonSource.exists
|
const jsonTarget = jsonSource.exists
|
||||||
? { config: jsonSource.config, path: jsonSource.path }
|
? { config: jsonSource.config, path: jsonSource.path }
|
||||||
: getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
|
: getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
||||||
let config = jsonTarget.config || {};
|
let config = jsonTarget.config || {};
|
||||||
|
|
||||||
// Determine if we should create a new md file:
|
// Determine if we should create a new md file:
|
||||||
@@ -742,7 +945,7 @@ function updateAgent(agentName, updates, workingDirectory) {
|
|||||||
jsonModified = true;
|
jsonModified = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For JSON-only agents, store prompt inline in JSON
|
// For JSON-only agents, store prompt inline in JSON
|
||||||
if (!config.agent) config.agent = {};
|
if (!config.agent) config.agent = {};
|
||||||
if (!config.agent[agentName]) config.agent[agentName] = {};
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||||
@@ -751,9 +954,79 @@ function updateAgent(agentName, updates, workingDirectory) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special handling for permission field - uses location detection and preserves non-wildcards
|
||||||
|
if (field === 'permission') {
|
||||||
|
const permissionSource = getAgentPermissionSource(agentName, workingDirectory);
|
||||||
|
const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName, workingDirectory);
|
||||||
|
|
||||||
|
if (permissionSource.source === 'md') {
|
||||||
|
// Write to existing .md file
|
||||||
|
const existingMdData = parseMdFile(permissionSource.path);
|
||||||
|
existingMdData.frontmatter.permission = newPermission;
|
||||||
|
writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body);
|
||||||
|
console.log(`Updated permission in .md file: ${permissionSource.path}`);
|
||||||
|
} else if (permissionSource.source === 'json') {
|
||||||
|
// Write to existing JSON location
|
||||||
|
const existingConfig = readConfigFile(permissionSource.path);
|
||||||
|
if (!existingConfig.agent) existingConfig.agent = {};
|
||||||
|
if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {};
|
||||||
|
existingConfig.agent[agentName].permission = newPermission;
|
||||||
|
writeConfig(existingConfig, permissionSource.path);
|
||||||
|
console.log(`Updated permission in JSON: ${permissionSource.path}`);
|
||||||
|
} else {
|
||||||
|
// Permission not defined anywhere - use agent's source location
|
||||||
|
if ((mdExists || creatingNewMd) && mdData) {
|
||||||
|
mdData.frontmatter.permission = newPermission;
|
||||||
|
mdModified = true;
|
||||||
|
} else if (hasJsonFields) {
|
||||||
|
// Agent exists in JSON - add permission there
|
||||||
|
if (!config.agent) config.agent = {};
|
||||||
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||||
|
config.agent[agentName].permission = newPermission;
|
||||||
|
jsonModified = true;
|
||||||
|
} else {
|
||||||
|
// Built-in agent with no config - write to project JSON if available, else user JSON
|
||||||
|
const writeTarget = workingDirectory
|
||||||
|
? { config: layers.projectConfig || {}, path: layers.paths.projectPath || layers.paths.userPath }
|
||||||
|
: { config: layers.userConfig || {}, path: layers.paths.userPath };
|
||||||
|
if (!writeTarget.config.agent) writeTarget.config.agent = {};
|
||||||
|
if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {};
|
||||||
|
writeTarget.config.agent[agentName].permission = newPermission;
|
||||||
|
writeConfig(writeTarget.config, writeTarget.path);
|
||||||
|
console.log(`Created permission in JSON: ${writeTarget.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
||||||
const inJson = jsonSection?.[field] !== undefined;
|
const inJson = jsonSection?.[field] !== undefined;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
// Treat null as a request to remove the field.
|
||||||
|
if (mdData && inMd) {
|
||||||
|
delete mdData.frontmatter[field];
|
||||||
|
mdModified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inJson) {
|
||||||
|
if (config.agent?.[agentName]) {
|
||||||
|
delete config.agent[agentName][field];
|
||||||
|
|
||||||
|
if (Object.keys(config.agent[agentName]).length === 0) {
|
||||||
|
delete config.agent[agentName];
|
||||||
|
}
|
||||||
|
if (Object.keys(config.agent).length === 0) {
|
||||||
|
delete config.agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonModified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// JSON takes precedence over md, so update JSON first if field exists there
|
// JSON takes precedence over md, so update JSON first if field exists there
|
||||||
if (inJson) {
|
if (inJson) {
|
||||||
if (!config.agent) config.agent = {};
|
if (!config.agent) config.agent = {};
|
||||||
@@ -852,6 +1125,7 @@ function getCommandSources(commandName, workingDirectory) {
|
|||||||
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
||||||
const jsonSection = jsonSource.section;
|
const jsonSection = jsonSource.section;
|
||||||
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
||||||
|
const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER;
|
||||||
|
|
||||||
const sources = {
|
const sources = {
|
||||||
md: {
|
md: {
|
||||||
@@ -863,6 +1137,7 @@ function getCommandSources(commandName, workingDirectory) {
|
|||||||
json: {
|
json: {
|
||||||
exists: jsonSource.exists,
|
exists: jsonSource.exists,
|
||||||
path: jsonPath,
|
path: jsonPath,
|
||||||
|
scope: jsonSource.exists ? jsonScope : null,
|
||||||
fields: []
|
fields: []
|
||||||
},
|
},
|
||||||
// Additional info about both levels
|
// Additional info about both levels
|
||||||
@@ -1373,6 +1648,8 @@ function deleteSkill(skillName, workingDirectory) {
|
|||||||
export {
|
export {
|
||||||
getAgentSources,
|
getAgentSources,
|
||||||
getAgentScope,
|
getAgentScope,
|
||||||
|
getAgentPermissionSource,
|
||||||
|
getAgentConfig,
|
||||||
createAgent,
|
createAgent,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
|
|||||||
Reference in New Issue
Block a user