Compare commits
21 Commits
v0.13.1-de
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d6a5bcdc0 | ||
|
|
514b187b00 | ||
|
|
240acb7729 | ||
|
|
0af79002ed | ||
|
|
f3981a1cce | ||
|
|
031e8d5717 | ||
|
|
995fb3b6a3 | ||
|
|
aeb0ff11b3 | ||
|
|
b61cfbd9f9 | ||
|
|
481dd1a88a | ||
|
|
3f6cdd36f3 | ||
|
|
fe932c8307 | ||
|
|
64ac885157 | ||
|
|
1d953dfe64 | ||
|
|
42589464e5 | ||
|
|
197dee2aea | ||
|
|
045d8da8b2 | ||
|
|
c9bd4b7395 | ||
|
|
41a5026331 | ||
|
|
d1a27ac31b | ||
|
|
37b3f85e61 |
171
README.md
171
README.md
@@ -1,128 +1,127 @@
|
||||
# CodeNomad
|
||||
|
||||
## A fast, multi-instance workspace for running OpenCode sessions.
|
||||
## The AI Coding Cockpit for OpenCode
|
||||
|
||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
|
||||
CodeNomad transforms OpenCode from a terminal tool into a **premium desktop workspace** — built for developers who live inside AI coding sessions for hours and need control, speed, and clarity.
|
||||
|
||||
> OpenCode gives you the engine. CodeNomad gives you the cockpit.
|
||||
|
||||

|
||||
_Manage multiple OpenCode sessions side-by-side._
|
||||
|
||||
<details>
|
||||
<summary>📸 More Screenshots</summary>
|
||||
---
|
||||
|
||||

|
||||
_Global command palette for keyboard-first control._
|
||||
## Features
|
||||
|
||||

|
||||
_Rich media previews for images and assets._
|
||||
- **🚀 Multi-Instance Workspace**
|
||||
- **🌐 Remote Access**
|
||||
- **🧠 Session Management**
|
||||
- **🎙️ Voice Input & Speech**
|
||||
- **🌳 Git Worktrees**
|
||||
- **💬 Rich Message Experience**
|
||||
- **⌨️ Command Palette**
|
||||
- **📁 File System Browser**
|
||||
- **🔐 Authentication & Security**
|
||||
- **🔔 Notifications**
|
||||
- **🎨 Theming**
|
||||
- **🌍 Internationalization**
|
||||
|
||||

|
||||
_Browser support via CodeNomad Server._
|
||||
|
||||
</details>
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
Choose the way that fits your workflow:
|
||||
### 🖥️ Desktop App
|
||||
|
||||
### 🖥️ Desktop App (Recommended)
|
||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
||||
Available as both Electron and Tauri builds — choose based on your preference.
|
||||
|
||||
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Run**: Install and launch like any other app.
|
||||
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
|
||||
|
||||
### 🦀 Tauri App (Experimental)
|
||||
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
||||
|
||||
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
||||
| Platform | Formats |
|
||||
|----------|---------|
|
||||
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
|
||||
| Windows | NSIS Installer, ZIP (x64, ARM64) |
|
||||
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
|
||||
|
||||
### 💻 CodeNomad Server
|
||||
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
|
||||
|
||||
Run as a local server and access via browser. Perfect for remote development.
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||
- [packages/server/README.md](packages/server/README.md)
|
||||
|
||||
To see all available options:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
|
||||
|
||||
### 🧪 Dev Releases
|
||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||
|
||||
Bleeding-edge builds from the `dev` branch:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||
- **Node.js 18+** — for server mode or building from source
|
||||
|
||||
## Troubleshooting
|
||||
---
|
||||
|
||||
### macOS says the app is damaged
|
||||
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
xattr -l /Applications/CodeNomad.app
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
||||
|
||||
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
|
||||
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
|
||||
|
||||
Try running with one of these environment variables:
|
||||
|
||||
```bash
|
||||
# Most reliable workaround (can reduce rendering performance)
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
|
||||
# Alternative for some Wayland setups
|
||||
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
|
||||
```
|
||||
|
||||
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
|
||||
```
|
||||
|
||||
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
|
||||
|
||||
## Architecture & Development
|
||||
|
||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||
CodeNomad is a monorepo built with:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
||||
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
||||
| **[packages/server](packages/server/README.md)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech |
|
||||
| **[packages/ui](packages/ui/README.md)** | SolidJS frontend — reactive, fast, beautiful |
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | Desktop shell — process management, IPC, native dialogs |
|
||||
| **[packages/tauri-app](packages/tauri-app)** | Tauri desktop shell (experimental) |
|
||||
|
||||
### Quick Build
|
||||
To build the Desktop App from source:
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repo.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||
```bash
|
||||
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
|
||||
cd CodeNomad
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS: "CodeNomad.app is damaged and can't be opened"</strong></summary>
|
||||
|
||||
Gatekeeper flag due to missing notarization. Clear the quarantine attribute:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
On Intel Macs, also check **System Settings → Privacy & Security** on first launch.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux (Wayland + NVIDIA): Tauri App closes immediately</strong></summary>
|
||||
|
||||
WebKitGTK DMA-BUF/GBM issue. Run with:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
```
|
||||
|
||||
See full workaround in the original README.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
|
||||
---
|
||||
|
||||
**Built with ♥ by [Neural Nomads](https://github.com/NeuralNomadsAI)** · [MIT License](LICENSE)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 845 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 835 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 966 KiB After Width: | Height: | Size: 1.1 MiB |
81
package-lock.json
generated
81
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -64,7 +64,6 @@
|
||||
"version": "7.28.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -3381,7 +3380,6 @@
|
||||
"version": "7.20.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
@@ -3483,7 +3481,6 @@
|
||||
"version": "22.19.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3558,7 +3555,6 @@
|
||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -3641,7 +3637,6 @@
|
||||
"version": "6.12.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -3844,6 +3839,7 @@
|
||||
"version": "5.3.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"async": "^3.2.4",
|
||||
@@ -3861,6 +3857,7 @@
|
||||
"version": "2.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.4",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -3881,6 +3878,7 @@
|
||||
"version": "2.3.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -3894,12 +3892,14 @@
|
||||
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -4213,6 +4213,7 @@
|
||||
"version": "4.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
@@ -4276,7 +4277,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4767,6 +4767,7 @@
|
||||
"version": "4.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"crc32-stream": "^4.0.2",
|
||||
@@ -4896,6 +4897,7 @@
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
@@ -4907,6 +4909,7 @@
|
||||
"version": "4.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
@@ -5272,7 +5275,6 @@
|
||||
"version": "24.13.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"builder-util": "24.13.1",
|
||||
@@ -5439,6 +5441,7 @@
|
||||
"version": "24.13.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"archiver": "^5.3.1",
|
||||
@@ -5450,6 +5453,7 @@
|
||||
"version": "10.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -5463,6 +5467,7 @@
|
||||
"version": "6.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -5474,6 +5479,7 @@
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -6191,7 +6197,8 @@
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
@@ -7408,7 +7415,8 @@
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/isbinaryfile": {
|
||||
"version": "5.0.6",
|
||||
@@ -7458,7 +7466,6 @@
|
||||
"version": "1.21.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -7590,6 +7597,7 @@
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
@@ -7601,6 +7609,7 @@
|
||||
"version": "2.3.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -7614,12 +7623,14 @@
|
||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -7684,22 +7695,26 @@
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.difference": {
|
||||
"version": "4.5.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.flatten": {
|
||||
"version": "4.4.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.sortby": {
|
||||
"version": "4.7.0",
|
||||
@@ -7711,7 +7726,8 @@
|
||||
"node_modules/lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
@@ -8515,7 +8531,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8663,7 +8678,8 @@
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "3.0.0",
|
||||
@@ -8912,6 +8928,7 @@
|
||||
"version": "3.6.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -8925,6 +8942,7 @@
|
||||
"version": "1.1.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
@@ -9227,7 +9245,6 @@
|
||||
"version": "4.52.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -9451,7 +9468,6 @@
|
||||
"node_modules/seroval": {
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -9775,7 +9791,6 @@
|
||||
"node_modules/solid-js": {
|
||||
"version": "1.9.10",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.3.0",
|
||||
@@ -9916,6 +9931,7 @@
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -10249,6 +10265,7 @@
|
||||
"version": "2.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
@@ -10441,7 +10458,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10691,7 +10707,6 @@
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11039,7 +11054,6 @@
|
||||
"version": "5.4.21",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -11524,7 +11538,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -11719,7 +11732,6 @@
|
||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
@@ -12008,6 +12020,7 @@
|
||||
"version": "4.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^3.0.4",
|
||||
"compress-commons": "^4.1.2",
|
||||
@@ -12021,6 +12034,7 @@
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.2.3",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -12040,7 +12054,6 @@
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -12055,7 +12068,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12092,7 +12105,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12134,7 +12147,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12142,7 +12155,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -22,7 +22,7 @@
|
||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
||||
"bumpVersion": "node ./scripts/bump-version.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.13.1",
|
||||
"minServerVersion": "0.13.3",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,23 @@ export interface Env {
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname === "/version.json") {
|
||||
const response = await env.ASSETS.fetch(request)
|
||||
|
||||
const newHeaders = new Headers(response.headers)
|
||||
newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
|
||||
newHeaders.set("Pragma", "no-cache")
|
||||
newHeaders.set("Expires", "0")
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
return env.ASSETS.fetch(request)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.3.2"
|
||||
"@opencode-ai/plugin": "1.3.7"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||
|
||||
let voiceModeEnabled = false
|
||||
|
||||
export async function CodeNomadPlugin(input: PluginInput) {
|
||||
const config = getCodeNomadConfig()
|
||||
const client = createCodeNomadClient(config)
|
||||
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
pingTs: (event.properties as any)?.ts,
|
||||
},
|
||||
}).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "codenomad.voiceMode") {
|
||||
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
tool: {
|
||||
...backgroundProcessTools,
|
||||
},
|
||||
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
|
||||
if (!voiceModeEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
|
||||
},
|
||||
async event(input: { event: any }) {
|
||||
const opencodeEvent = input?.event
|
||||
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||
@@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildVoiceModePrompt(): string {
|
||||
return [
|
||||
"Voice conversation mode is enabled.",
|
||||
"Prepend your reply with a fenced code block using language `spoken`.",
|
||||
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
|
||||
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
|
||||
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
|
||||
"Do not add generic phrases about whether the user should read more.",
|
||||
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
|
||||
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
|
||||
"After the `spoken` block, continue with your normal detailed response.",
|
||||
"Example:",
|
||||
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -240,6 +240,10 @@ export interface SpeechSynthesisResponse {
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface VoiceModeStateResponse {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
|
||||
128
packages/server/src/clients/connection-manager.ts
Normal file
128
packages/server/src/clients/connection-manager.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const STALE_CONNECTION_TIMEOUT_MS = 45000
|
||||
const STALE_SWEEP_INTERVAL_MS = 5000
|
||||
|
||||
export interface ClientConnectionRef {
|
||||
clientId: string
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
export interface ClientConnectionRecord extends ClientConnectionRef {
|
||||
key: string
|
||||
connectedAt: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
type ConnectionChangeEvent = {
|
||||
type: "connected" | "disconnected"
|
||||
connection: ClientConnectionRecord
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface RegisteredConnection extends ClientConnectionRecord {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export class ClientConnectionManager {
|
||||
private readonly connections = new Map<string, RegisteredConnection>()
|
||||
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
|
||||
private readonly sweepTimer: NodeJS.Timeout
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
|
||||
this.sweepTimer.unref?.()
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
clearInterval(this.sweepTimer)
|
||||
for (const connection of Array.from(this.connections.values())) {
|
||||
this.disconnect(connection.key, "shutdown", false)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
|
||||
this.subscribers.add(listener)
|
||||
return () => this.subscribers.delete(listener)
|
||||
}
|
||||
|
||||
register(input: ClientConnectionRef & { close: () => void }): () => void {
|
||||
const key = getConnectionKey(input)
|
||||
const now = Date.now()
|
||||
const existing = this.connections.get(key)
|
||||
|
||||
if (existing) {
|
||||
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
|
||||
this.disconnect(key, "replaced")
|
||||
}
|
||||
|
||||
const connection: RegisteredConnection = {
|
||||
key,
|
||||
clientId: input.clientId,
|
||||
connectionId: input.connectionId,
|
||||
connectedAt: now,
|
||||
lastSeenAt: now,
|
||||
close: input.close,
|
||||
}
|
||||
this.connections.set(key, connection)
|
||||
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
|
||||
this.notify({ type: "connected", connection })
|
||||
return () => this.disconnect(key, "closed")
|
||||
}
|
||||
|
||||
pong(input: ClientConnectionRef): boolean {
|
||||
const key = getConnectionKey(input)
|
||||
const connection = this.connections.get(key)
|
||||
if (!connection) {
|
||||
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
|
||||
return false
|
||||
}
|
||||
|
||||
connection.lastSeenAt = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
isConnected(input: ClientConnectionRef): boolean {
|
||||
return this.connections.has(getConnectionKey(input))
|
||||
}
|
||||
|
||||
private sweepStaleConnections(): void {
|
||||
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
|
||||
for (const connection of Array.from(this.connections.values())) {
|
||||
if (connection.lastSeenAt > cutoff) continue
|
||||
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
|
||||
this.disconnect(connection.key, "timeout")
|
||||
}
|
||||
}
|
||||
|
||||
private disconnect(key: string, reason: string, invokeClose = true): void {
|
||||
const connection = this.connections.get(key)
|
||||
if (!connection) return
|
||||
this.connections.delete(key)
|
||||
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
|
||||
|
||||
if (invokeClose) {
|
||||
try {
|
||||
connection.close()
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
|
||||
}
|
||||
}
|
||||
|
||||
this.notify({ type: "disconnected", connection, reason })
|
||||
}
|
||||
|
||||
private notify(event: ConnectionChangeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
try {
|
||||
subscriber(event)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionKey(input: ClientConnectionRef): string {
|
||||
return `${input.clientId}:${input.connectionId}`
|
||||
}
|
||||
@@ -81,6 +81,14 @@ export class FileSystemBrowser {
|
||||
return { path: relativePath, absolutePath }
|
||||
}
|
||||
|
||||
writeFile(relativePath: string, contents: string): void {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("writeFile is not available in unrestricted mode")
|
||||
}
|
||||
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||
fs.writeFileSync(resolved, contents, "utf-8")
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
|
||||
96
packages/server/src/plugins/voice-mode.ts
Normal file
96
packages/server/src/plugins/voice-mode.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Logger } from "../logger"
|
||||
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
|
||||
import type { PluginChannelManager } from "./channel"
|
||||
|
||||
interface VoiceModeManagerOptions {
|
||||
connections: ClientConnectionManager
|
||||
channel: PluginChannelManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export class VoiceModeManager {
|
||||
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
|
||||
private readonly aggregateByInstance = new Map<string, boolean>()
|
||||
|
||||
constructor(private readonly options: VoiceModeManagerOptions) {
|
||||
this.options.connections.subscribe((event) => {
|
||||
if (event.type !== "disconnected") return
|
||||
this.clearConnection(event.connection)
|
||||
})
|
||||
}
|
||||
|
||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
|
||||
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||
this.options.logger.debug(
|
||||
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||
"Ignoring voice mode enable for disconnected client connection",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const key = getConnectionKey(connection)
|
||||
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
|
||||
|
||||
if (enabled) {
|
||||
current.add(key)
|
||||
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||
} else if (current.delete(key)) {
|
||||
if (current.size === 0) {
|
||||
this.enabledConnectionsByInstance.delete(instanceId)
|
||||
} else {
|
||||
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||
}
|
||||
}
|
||||
|
||||
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||
this.publishIfChanged(instanceId)
|
||||
}
|
||||
|
||||
syncInstance(instanceId: string): void {
|
||||
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
|
||||
}
|
||||
|
||||
isEnabled(instanceId: string): boolean {
|
||||
return this.aggregateByInstance.get(instanceId) === true
|
||||
}
|
||||
|
||||
private clearConnection(connection: ClientConnectionRef): void {
|
||||
const key = getConnectionKey(connection)
|
||||
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
|
||||
if (!enabledConnections.delete(key)) continue
|
||||
if (enabledConnections.size === 0) {
|
||||
this.enabledConnectionsByInstance.delete(instanceId)
|
||||
}
|
||||
this.publishIfChanged(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private publishIfChanged(instanceId: string): void {
|
||||
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
|
||||
const previous = this.aggregateByInstance.get(instanceId) === true
|
||||
if (enabled === previous) return
|
||||
|
||||
if (enabled) {
|
||||
this.aggregateByInstance.set(instanceId, true)
|
||||
} else {
|
||||
this.aggregateByInstance.delete(instanceId)
|
||||
}
|
||||
|
||||
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
|
||||
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
function buildVoiceModeEvent(enabled: boolean) {
|
||||
return {
|
||||
type: "codenomad.voiceMode",
|
||||
properties: {
|
||||
enabled,
|
||||
formatVersion: "v1",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionKey(connection: ClientConnectionRef): string {
|
||||
return `${connection.clientId}:${connection.connectionId}`
|
||||
}
|
||||
@@ -29,6 +29,9 @@ import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
import type { SpeechService } from "../speech/service"
|
||||
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||
import { PluginChannelManager } from "../plugins/channel"
|
||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||
|
||||
interface HttpServerDeps {
|
||||
bindHost: string
|
||||
@@ -173,6 +176,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: deps.logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
@@ -248,7 +258,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||
registerEventRoutes(app, {
|
||||
eventBus: deps.eventBus,
|
||||
registerClient: registerSseClient,
|
||||
logger: sseLogger,
|
||||
connectionManager: clientConnectionManager,
|
||||
})
|
||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
@@ -256,7 +271,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||
registerPluginRoutes(app, {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
logger: proxyLogger,
|
||||
channel: pluginChannel,
|
||||
voiceModeManager,
|
||||
})
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
@@ -321,6 +342,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
clientConnectionManager.shutdown()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
import type { ClientConnectionManager } from "../../clients/connection-manager"
|
||||
import { Logger } from "../../logger"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
registerClient: (cleanup: () => void) => () => void
|
||||
logger: Logger
|
||||
connectionManager: ClientConnectionManager
|
||||
}
|
||||
|
||||
let nextClientId = 0
|
||||
|
||||
const ConnectionQuerySchema = z.object({
|
||||
clientId: z.string().trim().min(1),
|
||||
connectionId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
const PongBodySchema = ConnectionQuerySchema.extend({
|
||||
pingTs: z.number().optional(),
|
||||
})
|
||||
|
||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const clientId = ++nextClientId
|
||||
const connection = ConnectionQuerySchema.parse(request.query ?? {})
|
||||
deps.logger.debug({ clientId }, "SSE client connected")
|
||||
|
||||
const origin = request.headers.origin ?? "*"
|
||||
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
const unsubscribe = deps.eventBus.onEvent(send)
|
||||
const heartbeat = setInterval(() => {
|
||||
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
||||
const ping = { ts: Date.now() }
|
||||
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
|
||||
}, 15000)
|
||||
|
||||
let closed = false
|
||||
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const unregister = deps.registerClient(close)
|
||||
const unregisterConnection = deps.connectionManager.register({
|
||||
...connection,
|
||||
close,
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
close()
|
||||
unregister()
|
||||
unregisterConnection()
|
||||
}
|
||||
|
||||
request.raw.on("close", handleClose)
|
||||
request.raw.on("error", handleClose)
|
||||
})
|
||||
|
||||
app.post("/api/client-connections/pong", (request, reply) => {
|
||||
const body = PongBodySchema.parse(request.body ?? {})
|
||||
if (!deps.connectionManager.pong(body)) {
|
||||
reply.code(404).send({ error: "Client connection not found" })
|
||||
return
|
||||
}
|
||||
reply.code(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { VoiceModeStateResponse } from "../../api-types"
|
||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||
import type { EventBus } from "../../events/bus"
|
||||
import type { Logger } from "../../logger"
|
||||
import { PluginChannelManager } from "../../plugins/channel"
|
||||
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
||||
import { VoiceModeManager } from "../../plugins/voice-mode"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
channel: PluginChannelManager
|
||||
voiceModeManager: VoiceModeManager
|
||||
}
|
||||
|
||||
const PluginEventSchema = z.object({
|
||||
@@ -17,9 +21,13 @@ const PluginEventSchema = z.object({
|
||||
properties: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const VoiceModeStateSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
clientId: z.string().trim().min(1),
|
||||
connectionId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
@@ -33,10 +41,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const registration = channel.register(request.params.id, reply)
|
||||
const registration = deps.channel.register(request.params.id, reply)
|
||||
deps.voiceModeManager.syncInstance(request.params.id)
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
channel.send(request.params.id, buildPingEvent())
|
||||
deps.channel.send(request.params.id, buildPingEvent())
|
||||
}, 15000)
|
||||
|
||||
const close = () => {
|
||||
@@ -49,6 +58,22 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
request.raw.on("error", close)
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||
deps.voiceModeManager.setEnabled(
|
||||
request.params.id,
|
||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||
payload.enabled,
|
||||
)
|
||||
return { enabled: payload.enabled }
|
||||
})
|
||||
|
||||
const handleWildcard = async (request: any, reply: any) => {
|
||||
const workspaceId = request.params.id as string
|
||||
const workspace = deps.workspaceManager.get(workspaceId)
|
||||
|
||||
@@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileContentBodySchema = z.object({
|
||||
contents: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
@@ -100,6 +104,20 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.put<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
|
||||
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
|
||||
assert.equal(result.uiStaticDir, bundledDir)
|
||||
assert.equal(result.uiVersion, "0.8.1")
|
||||
})
|
||||
|
||||
it("prefers bundled when bundled and downloaded versions are equal", async () => {
|
||||
const bundledDir = path.join(tempRoot, "bundled")
|
||||
const configDir = path.join(tempRoot, "config")
|
||||
const currentDir = path.join(configDir, "ui", "current")
|
||||
|
||||
await mkdir(bundledDir, { recursive: true })
|
||||
await mkdir(currentDir, { recursive: true })
|
||||
|
||||
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
|
||||
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||
|
||||
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
||||
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||
|
||||
const result = await resolveUi({
|
||||
serverVersion: "0.8.1",
|
||||
bundledUiDir: bundledDir,
|
||||
autoUpdate: false,
|
||||
configDir,
|
||||
logger: noopLogger,
|
||||
})
|
||||
|
||||
assert.equal(result.source, "bundled")
|
||||
assert.equal(result.uiStaticDir, bundledDir)
|
||||
assert.equal(result.uiVersion, "0.8.1")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
|
||||
uiStaticDir: currentResolved,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(currentResolved),
|
||||
priority: 2,
|
||||
priority: 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
|
||||
uiStaticDir: bundledResolved,
|
||||
source: "bundled",
|
||||
uiVersion: await readUiVersion(bundledResolved),
|
||||
priority: 1,
|
||||
priority: 2,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,12 @@ export class WorkspaceManager {
|
||||
}
|
||||
}
|
||||
|
||||
writeFile(workspaceId: string, relativePath: string, contents: string): void {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
browser.writeFile(relativePath, contents)
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
|
||||
2
packages/tauri-app/Cargo.lock
generated
2
packages/tauri-app/Cargo.lock
generated
@@ -458,7 +458,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.12.3"
|
||||
version = "0.13.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
"dev:prep": "node ./scripts/dev-prep.js",
|
||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||
"sync:version": "node ./scripts/sync-tauri-version.js",
|
||||
"prebuild": "node ./scripts/prebuild.js",
|
||||
"bundle:server": "npm run prebuild",
|
||||
"build": "tauri build"
|
||||
|
||||
@@ -56,11 +56,7 @@ async function ensureMonacoAssets() {
|
||||
function ensureServerBuild() {
|
||||
const distPath = path.join(serverRoot, "dist")
|
||||
const publicPath = path.join(serverRoot, "public")
|
||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] server build missing; running workspace build...")
|
||||
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
|
||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
|
||||
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const packageJsonPath = path.join(root, "package.json")
|
||||
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
|
||||
const cargoLockPath = path.join(root, "Cargo.lock")
|
||||
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
|
||||
|
||||
function readPackageVersion() {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error("Missing version in packages/tauri-app/package.json")
|
||||
}
|
||||
return packageJson.version
|
||||
}
|
||||
|
||||
function syncCargoToml(version) {
|
||||
const current = fs.readFileSync(cargoTomlPath, "utf8")
|
||||
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
|
||||
const match = current.match(packageVersionPattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
|
||||
}
|
||||
|
||||
if (match[2] === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||
fs.writeFileSync(cargoTomlPath, updated)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncCargoLock(version) {
|
||||
if (!fs.existsSync(cargoLockPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const current = fs.readFileSync(cargoLockPath, "utf8")
|
||||
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
|
||||
const match = current.match(packageVersionPattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
|
||||
}
|
||||
|
||||
if (match[2] === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||
fs.writeFileSync(cargoLockPath, updated)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncTauriConfig(version) {
|
||||
const current = fs.readFileSync(tauriConfigPath, "utf8")
|
||||
const config = JSON.parse(current)
|
||||
if (config.version === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
config.version = version
|
||||
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
return true
|
||||
}
|
||||
|
||||
function main() {
|
||||
const version = readPackageVersion()
|
||||
const changed = []
|
||||
|
||||
if (syncCargoToml(version)) {
|
||||
changed.push(path.relative(root, cargoTomlPath))
|
||||
}
|
||||
|
||||
if (syncCargoLock(version)) {
|
||||
changed.push(path.relative(root, cargoLockPath))
|
||||
}
|
||||
|
||||
if (syncTauriConfig(version)) {
|
||||
changed.push(path.relative(root, tauriConfigPath))
|
||||
}
|
||||
|
||||
if (changed.length === 0) {
|
||||
console.log(`[sync-tauri-version] already aligned to ${version}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[sync-tauri-version] failed: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.12.3"
|
||||
version = "0.13.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.3",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
"frontendDist": "resources/ui-loading"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
@@ -33,9 +30,13 @@
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"scope": ["**"]
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"capabilities": ["main-window-native-dialogs"]
|
||||
"capabilities": [
|
||||
"main-window-native-dialogs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -44,7 +45,17 @@
|
||||
"resources/server",
|
||||
"resources/ui-loading"
|
||||
],
|
||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||
"icon": [
|
||||
"icon.icns",
|
||||
"icon.ico",
|
||||
"icon.png"
|
||||
],
|
||||
"targets": [
|
||||
"app",
|
||||
"appimage",
|
||||
"deb",
|
||||
"rpm",
|
||||
"nsis"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
|
||||
open
|
||||
modal
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Only handle dismiss if dialog is dismissible (default: true)
|
||||
if (!open && payload.dismissible !== false) {
|
||||
dismiss(false, payload)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<Dialog.Overlay class="modal-overlay z-[60]" />
|
||||
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
@@ -140,10 +140,11 @@ const AlertDialog: Component = () => {
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-medium text-secondary">
|
||||
<label for="prompt-input" class="text-sm font-medium text-secondary">
|
||||
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="prompt-input"
|
||||
ref={(el) => {
|
||||
promptInputRef = el
|
||||
}}
|
||||
@@ -184,11 +185,10 @@ const AlertDialog: Component = () => {
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
@@ -9,6 +9,8 @@ interface MonacoFileViewerProps {
|
||||
scopeKey: string
|
||||
path: string
|
||||
content: string
|
||||
onSave?: (content: string) => void
|
||||
onContentChange?: (content: string) => void
|
||||
}
|
||||
|
||||
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
@@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
editor = null
|
||||
}
|
||||
|
||||
const saveContent = () => {
|
||||
if (!editor || !props.onSave) return
|
||||
props.onSave(editor.getValue())
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
editor = monaco.editor.create(host, {
|
||||
value: "",
|
||||
language: "plaintext",
|
||||
readOnly: true,
|
||||
readOnly: false,
|
||||
automaticLayout: true,
|
||||
lineNumbers: "on",
|
||||
minimap: { enabled: false },
|
||||
@@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
fontSize: 13,
|
||||
})
|
||||
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
if (props.onContentChange) {
|
||||
props.onContentChange(editor.getValue())
|
||||
}
|
||||
})
|
||||
|
||||
setReady(true)
|
||||
})()
|
||||
|
||||
|
||||
@@ -443,7 +443,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
aria-label={t("folderSelection.links.githubStars")}
|
||||
title={t("folderSelection.links.githubStars")}
|
||||
title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
|
||||
@@ -44,6 +44,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
||||
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
@@ -36,12 +36,12 @@ import { serverApi } from "../../lib/api-client"
|
||||
import { loadBackgroundProcesses } from "../../stores/background-processes"
|
||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances"
|
||||
import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances"
|
||||
import SessionSidebar from "./shell/SessionSidebar"
|
||||
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
||||
import RightPanel from "./shell/right-panel/RightPanel"
|
||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||
import { getSessionStatus } from "../../stores/session-status"
|
||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||
|
||||
import type { LayoutMode } from "./shell/types"
|
||||
@@ -57,6 +57,13 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
|
||||
import { useDrawerResize } from "./shell/useDrawerResize"
|
||||
import { useSessionCache } from "./shell/useSessionCache"
|
||||
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
|
||||
import { getPermissionSessionId } from "../../types/permission"
|
||||
import {
|
||||
canAutoRespondPermission,
|
||||
finishAutoRespondPermission,
|
||||
getPermissionAutoAcceptInFlightVersion,
|
||||
isPermissionAutoAcceptEnabled,
|
||||
} from "../../stores/permission-auto-accept"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -97,6 +104,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
|
||||
// Worktree selector manages its own dialogs.
|
||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||
@@ -230,6 +238,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
onCleanup(() => window.clearInterval(timer))
|
||||
})
|
||||
|
||||
const connectionStatus = () => sseManager.getStatus(props.instance.id)
|
||||
const connectionStatusClass = () => {
|
||||
const status = connectionStatus()
|
||||
@@ -252,6 +266,33 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return permissions + questions > 0
|
||||
})
|
||||
|
||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
||||
|
||||
createEffect(() => {
|
||||
getPermissionAutoAcceptInFlightVersion()
|
||||
|
||||
for (const permission of permissionQueue()) {
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
if (!sessionId) continue
|
||||
if (!permission?.id) continue
|
||||
if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue
|
||||
|
||||
void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once")
|
||||
.catch((error) => {
|
||||
log.error("Failed to auto-accept permission", error)
|
||||
})
|
||||
.finally(() => {
|
||||
finishAutoRespondPermission(props.instance.id, sessionId, permission.id)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const yoloModeEnabled = createMemo(() => {
|
||||
const session = activeSessionForInstance()
|
||||
if (!session) return false
|
||||
return isPermissionAutoAcceptEnabled(props.instance.id, session.id)
|
||||
})
|
||||
|
||||
const activeSessionStatusPill = createMemo(() => {
|
||||
const activeSessionId = activeSessionIdForInstance()
|
||||
if (!activeSessionId || activeSessionId === "info") return null
|
||||
@@ -272,17 +313,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
}
|
||||
|
||||
const status = getSessionStatus(props.instance.id, activeSessionId)
|
||||
const text =
|
||||
status === "working"
|
||||
const retry = getSessionRetry(props.instance.id, activeSessionId)
|
||||
const text = retry
|
||||
? (() => {
|
||||
const seconds = getRetrySeconds(retry.next, now())
|
||||
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
|
||||
})()
|
||||
: status === "working"
|
||||
? t("sessionList.status.working")
|
||||
: status === "compacting"
|
||||
? t("sessionList.status.compacting")
|
||||
: t("sessionList.status.idle")
|
||||
|
||||
return {
|
||||
className: `session-${status}`,
|
||||
className: `session-${retry ? "retrying" : status}`,
|
||||
text,
|
||||
showAlertIcon: false,
|
||||
title: retry
|
||||
? t("sessionList.status.retryTooltip", {
|
||||
message: retry.message,
|
||||
attempt: String(retry.attempt),
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -290,13 +342,39 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const pill = activeSessionStatusPill()
|
||||
if (!pill) return null
|
||||
return (
|
||||
<span class={`status-indicator session-status session-status-list ${pill.className}`}>
|
||||
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
|
||||
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{pill.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderYoloModePill = () => {
|
||||
if (!yoloModeEnabled()) return null
|
||||
return (
|
||||
<span
|
||||
class="status-indicator session-status session-status-list session-yolo-mode"
|
||||
aria-label={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||
title={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||
>
|
||||
<span class="status-dot" />
|
||||
{t("instanceShell.yoloMode.badge")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSessionHeaderIndicators = () => (
|
||||
<div class="flex items-center flex-wrap justify-center gap-2">
|
||||
{renderYoloModePill()}
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleCommandPaletteClick = () => {
|
||||
showCommandPalette(props.instance.id)
|
||||
}
|
||||
@@ -420,6 +498,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onClose={closeLeftDrawer}
|
||||
ModalProps={modalProps}
|
||||
sx={{
|
||||
zIndex: 60,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
@@ -530,6 +609,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onClose={closeRightDrawer}
|
||||
ModalProps={modalProps}
|
||||
sx={{
|
||||
zIndex: 60,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
@@ -620,12 +700,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center min-w-0">
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
{renderSessionHeaderIndicators()}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||
@@ -717,12 +792,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="ml-auto flex items-center session-header-hints">
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
{renderSessionHeaderIndicators()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,104 +48,103 @@ interface SessionSidebarProps {
|
||||
}
|
||||
|
||||
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||
title={props.t("sessionList.actions.newSession.title")}
|
||||
onClick={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlusSquare class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||
title={props.t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={props.showSearch()}
|
||||
onClick={props.onToggleSearch}
|
||||
sx={{
|
||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Search class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => props.onSelectSession("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||
title={props.t("sessionList.actions.newSession.title")}
|
||||
onClick={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<PlusSquare class="w-5 h-5" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={props.drawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={props.onCloseLeftDrawer}
|
||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||
title={props.t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={props.showSearch()}
|
||||
onClick={props.onToggleSearch}
|
||||
sx={{
|
||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
<Search class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => props.onSelectSession("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||
>
|
||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={props.drawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={props.onCloseLeftDrawer}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={props.keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={props.keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instanceId}
|
||||
threads={props.threads()}
|
||||
activeSessionId={props.activeSessionId()}
|
||||
onSelect={props.onSelectSession}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={props.showSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instanceId}
|
||||
threads={props.threads()}
|
||||
activeSessionId={props.activeSessionId()}
|
||||
onSelect={props.onSelectSession}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={props.showSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
|
||||
|
||||
@@ -177,11 +176,10 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default SessionSidebar
|
||||
|
||||
@@ -24,6 +24,9 @@ import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } f
|
||||
|
||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { serverApi } from "../../../../lib/api-client"
|
||||
import { showConfirmDialog } from "../../../../stores/alerts"
|
||||
import { showToastNotification } from "../../../../lib/notifications"
|
||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||
import {
|
||||
@@ -86,6 +89,7 @@ interface RightPanelProps {
|
||||
const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
||||
"yolo-mode",
|
||||
"plan",
|
||||
"background-processes",
|
||||
"mcp",
|
||||
@@ -102,6 +106,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
|
||||
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
|
||||
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
|
||||
|
||||
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||
@@ -539,6 +546,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setBrowserSelectedLoading(true)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedDirty(false)
|
||||
setBrowserSelectedOriginalContent(null)
|
||||
|
||||
// Phone: treat file selection as a commit action and close the overlay.
|
||||
if (props.isPhoneLayout()) {
|
||||
@@ -559,6 +568,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
setBrowserSelectedContent(text)
|
||||
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
@@ -566,6 +576,95 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const saveBrowserFile = async (content: string): Promise<boolean> => {
|
||||
const path = browserSelectedPath()
|
||||
if (!path) return false
|
||||
|
||||
// Check for conflict: agent edited file while user was editing
|
||||
const originalContent = browserSelectedOriginalContent()
|
||||
if (originalContent !== null) {
|
||||
try {
|
||||
const currentDiskContent = await requestData<FileContent>(
|
||||
browserClient().file.read({ path }),
|
||||
"file.read",
|
||||
)
|
||||
const diskContent = (currentDiskContent as any)?.content
|
||||
|
||||
// If disk content differs from what we originally loaded (agent edit)
|
||||
// AND differs from user's current edits, we have a conflict
|
||||
if (diskContent !== originalContent && diskContent !== content) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
props.t("instanceShell.rightPanel.actions.conflict.message", { path }),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
|
||||
cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (!confirmed) {
|
||||
return false
|
||||
}
|
||||
// User chose to overwrite, proceed with save
|
||||
}
|
||||
} catch {
|
||||
// If we can't check for conflict, proceed with save
|
||||
}
|
||||
}
|
||||
|
||||
setBrowserSelectedSaving(true)
|
||||
try {
|
||||
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
|
||||
setBrowserSelectedContent(content)
|
||||
setBrowserSelectedOriginalContent(content) // Update original to match saved
|
||||
setBrowserSelectedDirty(false)
|
||||
showToastNotification({
|
||||
message: props.t("instanceShell.rightPanel.toast.saveSuccess"),
|
||||
variant: "success",
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
|
||||
showToastNotification({
|
||||
message: props.t("instanceShell.rightPanel.toast.saveError"),
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
setBrowserSelectedSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowserFileChange = (content: string) => {
|
||||
setBrowserSelectedContent(content)
|
||||
setBrowserSelectedDirty(true)
|
||||
}
|
||||
|
||||
const handleOpenBrowserFileRequest = async (path: string) => {
|
||||
if (browserSelectedDirty()) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
|
||||
cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (confirmed) {
|
||||
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
|
||||
if (!saveSuccess) {
|
||||
// Save failed - stay on current file, error toast already shown
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// User chose not to save - clear dirty state and discard edits
|
||||
setBrowserSelectedDirty(false)
|
||||
}
|
||||
}
|
||||
await openBrowserFile(path)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "files") return
|
||||
if (browserLoading()) return
|
||||
@@ -578,6 +677,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedLoading(false)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedDirty(false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -630,6 +730,22 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
}
|
||||
|
||||
const refreshFilesTab = async () => {
|
||||
// Prompt for confirmation if file has unsaved changes
|
||||
if (browserSelectedDirty()) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
props.t("instanceShell.rightPanel.actions.refreshDirty.message"),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
|
||||
cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
void loadBrowserEntries(browserPath())
|
||||
const selected = browserSelectedPath()
|
||||
if (selected) {
|
||||
@@ -651,6 +767,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
setBrowserSelectedContent(text)
|
||||
setBrowserSelectedOriginalContent(text) // Update original content after refresh
|
||||
setBrowserSelectedDirty(false) // Clear dirty after refresh
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
@@ -670,7 +788,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setRightPanelTab("changes")
|
||||
}
|
||||
|
||||
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||
const statusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||
|
||||
createEffect(() => {
|
||||
const currentExpanded = new Set(rightPanelExpandedItems())
|
||||
@@ -830,11 +948,15 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
browserSelectedContent={browserSelectedContent}
|
||||
browserSelectedLoading={browserSelectedLoading}
|
||||
browserSelectedError={browserSelectedError}
|
||||
browserSelectedDirty={browserSelectedDirty}
|
||||
browserSelectedSaving={browserSelectedSaving}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
||||
onOpenFile={(path: string) => void openBrowserFile(path)}
|
||||
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
onSave={(content: string) => void saveBrowserFile(content)}
|
||||
onContentChange={(content: string) => handleBrowserFileChange(content)}
|
||||
listOpen={filesListOpen}
|
||||
onToggleList={toggleFilesList}
|
||||
splitWidth={filesSplitWidth}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
import { RefreshCw, Save } from "lucide-solid"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
|
||||
@@ -21,13 +21,17 @@ interface FilesTabProps {
|
||||
browserSelectedContent: Accessor<string | null>
|
||||
browserSelectedLoading: Accessor<boolean>
|
||||
browserSelectedError: Accessor<string | null>
|
||||
browserSelectedDirty: Accessor<boolean>
|
||||
browserSelectedSaving: Accessor<boolean>
|
||||
|
||||
parentPath: Accessor<string | null>
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
onLoadEntries: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
onRequestOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
onSave: (content: string) => void
|
||||
onContentChange: (content: string) => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
@@ -38,6 +42,13 @@ interface FilesTabProps {
|
||||
}
|
||||
|
||||
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const handleSave = () => {
|
||||
const content = props.browserSelectedContent()
|
||||
if (content !== undefined && content !== null) {
|
||||
props.onSave(content)
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
const entriesValue = props.browserEntries()
|
||||
const entries = entriesValue || []
|
||||
@@ -86,7 +97,13 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
<LazyMonacoFileViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={payload().path}
|
||||
content={payload().content}
|
||||
onSave={props.onSave}
|
||||
onContentChange={props.onContentChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
@@ -135,7 +152,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
props.onLoadEntries(item.path)
|
||||
return
|
||||
}
|
||||
props.onOpenFile(item.path)
|
||||
props.onRequestOpenFile(item.path)
|
||||
}}
|
||||
title={item.path}
|
||||
>
|
||||
@@ -168,14 +185,25 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</Show>
|
||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
|
||||
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
|
||||
style={{ "margin-inline-start": "auto" }}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>
|
||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={props.browserLoading()}
|
||||
style={{ "margin-inline-start": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||
@@ -198,4 +226,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default FilesTab
|
||||
export default FilesTab
|
||||
@@ -2,6 +2,7 @@ import { For, Show, type Accessor, type Component } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import { Accordion } from "@kobalte/core"
|
||||
import { Tooltip } from "@kobalte/core/tooltip"
|
||||
import Switch from "@suid/material/Switch"
|
||||
|
||||
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
|
||||
@@ -12,6 +13,7 @@ import type { Session } from "../../../../../types/session"
|
||||
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
||||
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
||||
import InstanceServiceStatus from "../../../../instance-service-status"
|
||||
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
|
||||
|
||||
interface StatusTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
@@ -39,6 +41,35 @@ interface StatusTabProps {
|
||||
const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
||||
|
||||
const renderYoloModeSection = () => {
|
||||
const session = props.activeSession()
|
||||
if (!session) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.yoloMode.noSessionSelected")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base bg-surface-secondary px-3 py-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-primary">{props.t("instanceShell.yoloMode.title")}</div>
|
||||
<p class="mt-1 text-xs text-secondary">{props.t("instanceShell.yoloMode.description")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPermissionAutoAcceptEnabled(props.instanceId, session.id)}
|
||||
color="warning"
|
||||
size="small"
|
||||
inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }}
|
||||
onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderStatusSessionChanges = () => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
@@ -204,6 +235,12 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
}
|
||||
|
||||
const statusSections = [
|
||||
{
|
||||
id: "yolo-mode",
|
||||
labelKey: "instanceShell.rightPanel.sections.yoloMode",
|
||||
tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
|
||||
render: renderYoloModeSection,
|
||||
},
|
||||
{
|
||||
id: "session-changes",
|
||||
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||
@@ -281,29 +318,23 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
<For each={statusSections}>
|
||||
{(section) => (
|
||||
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||
<Accordion.Header>
|
||||
<Accordion.Header class="right-panel-accordion-header-row">
|
||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||
<span class="section-left">
|
||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||
<Tooltip.Trigger
|
||||
class="section-info-trigger"
|
||||
aria-label={props.t(section.tooltipKey)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info class="section-info-icon" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="section-info-tooltip">
|
||||
{props.t(section.tooltipKey)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip>
|
||||
<span class="section-label">{props.t(section.labelKey)}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
|
||||
<Info class="section-info-icon" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
@@ -83,6 +83,7 @@ interface MarkdownProps {
|
||||
isDark?: boolean
|
||||
size?: "base" | "sm" | "tight"
|
||||
disableHighlight?: boolean
|
||||
escapeRawHtml?: boolean
|
||||
onRendered?: () => void
|
||||
}
|
||||
|
||||
@@ -103,11 +104,12 @@ export function Markdown(props: MarkdownProps) {
|
||||
const text = decodeHtmlEntitiesLocally(rawText)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
const highlightEnabled = !props.disableHighlight
|
||||
const escapeRawHtml = Boolean(props.escapeRawHtml)
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||
const cacheId = resolvePartCacheId(part, text)
|
||||
const version = resolvePartVersion(part, text)
|
||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
||||
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
|
||||
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
|
||||
})
|
||||
|
||||
const cacheHandle = useGlobalCache({
|
||||
@@ -116,20 +118,26 @@ export function Markdown(props: MarkdownProps) {
|
||||
scope: "markdown",
|
||||
cacheId: () => {
|
||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
|
||||
},
|
||||
version: () => resolved().version,
|
||||
})
|
||||
|
||||
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
||||
const commitCacheEntry = (
|
||||
snapshot: ReturnType<typeof resolved>,
|
||||
renderedHtml: string,
|
||||
options?: { cache?: boolean },
|
||||
) => {
|
||||
const cacheEntry: RenderCache = {
|
||||
text: snapshot.text,
|
||||
html: renderedHtml,
|
||||
theme: snapshot.themeKey,
|
||||
mode: snapshot.version,
|
||||
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
|
||||
}
|
||||
setHtml(renderedHtml)
|
||||
cacheHandle.set(cacheEntry)
|
||||
if (options?.cache ?? true) {
|
||||
cacheHandle.set(cacheEntry)
|
||||
}
|
||||
notifyRendered()
|
||||
}
|
||||
|
||||
@@ -138,20 +146,23 @@ export function Markdown(props: MarkdownProps) {
|
||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||
suppressHighlight: !snapshot.highlightEnabled,
|
||||
escapeRawHtml: snapshot.escapeRawHtml,
|
||||
})
|
||||
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
|
||||
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
commitCacheEntry(snapshot, rendered)
|
||||
commitCacheEntry(snapshot, rendered, { cache: shouldCache })
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const snapshot = resolved()
|
||||
latestRequestKey = snapshot.requestKey
|
||||
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
|
||||
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
if (!cache) return false
|
||||
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
||||
return cache.theme === snapshot.themeKey && cache.mode === cacheMode
|
||||
}
|
||||
|
||||
const localCache = snapshot.part.renderCache
|
||||
|
||||
@@ -146,6 +146,7 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
escapeRawHtml={props.messageType === "user"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -19,7 +19,12 @@ import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
||||
import { canUseConversationMode, isConversationModeEnabled, toggleConversationMode } from "../stores/conversation-speech"
|
||||
import {
|
||||
canUseConversationMode,
|
||||
clearConversationPlaybackForInstance,
|
||||
isConversationModeEnabled,
|
||||
toggleConversationMode,
|
||||
} from "../stores/conversation-speech"
|
||||
const log = getLogger("actions")
|
||||
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
|
||||
|
||||
@@ -492,6 +497,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
|
||||
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
|
||||
voiceButtonPressed = true
|
||||
// Treat a mic press as barge-in: stop any active assistant speech before listening.
|
||||
clearConversationPlaybackForInstance(props.instanceId)
|
||||
|
||||
if (event instanceof PointerEvent) {
|
||||
const target = event.currentTarget
|
||||
|
||||
@@ -98,6 +98,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
variant: "warning",
|
||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
||||
import type { SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid"
|
||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ensureSessionParentExpanded,
|
||||
getVisibleSessionIds,
|
||||
isSessionParentExpanded,
|
||||
loadMessages,
|
||||
loading,
|
||||
renameSession,
|
||||
sessions as sessionStateSessions,
|
||||
@@ -53,6 +54,14 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
|
||||
|
||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
onCleanup(() => window.clearInterval(timer))
|
||||
})
|
||||
|
||||
const normalizeSessionLabel = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
@@ -157,6 +166,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.delete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.delete.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (!confirmed) return
|
||||
@@ -212,6 +222,32 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
||||
}
|
||||
|
||||
const isSessionReloading = (sessionId: string) => reloadingSessionIds().has(sessionId)
|
||||
|
||||
const handleReloadSession = async (event: MouseEvent, sessionId: string) => {
|
||||
event.stopPropagation()
|
||||
if (isSessionReloading(sessionId)) return
|
||||
|
||||
setReloadingSessionIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(sessionId)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
await loadMessages(props.instanceId, sessionId, true)
|
||||
} catch (error) {
|
||||
log.error(`Failed to reload session ${sessionId}:`, error)
|
||||
showToastNotification({ message: t("sessionList.reload.error"), variant: "error" })
|
||||
} finally {
|
||||
setReloadingSessionIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(sessionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const closeRenameDialog = () => {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
@@ -285,6 +321,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -370,7 +407,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||
const title = () => session()?.title || t("sessionList.session.untitled")
|
||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||
const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId)
|
||||
const statusLabel = () => {
|
||||
const retryState = retry()
|
||||
if (retryState) {
|
||||
const seconds = getRetrySeconds(retryState.next, now())
|
||||
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
|
||||
}
|
||||
switch (formatSessionStatus(status())) {
|
||||
case "working":
|
||||
return t("sessionList.status.working")
|
||||
@@ -383,13 +426,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||
const needsInput = () => needsPermission() || needsQuestion()
|
||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`)
|
||||
const statusText = () =>
|
||||
needsPermission()
|
||||
? t("sessionList.status.needsPermission")
|
||||
: needsQuestion()
|
||||
? t("sessionList.status.needsInput")
|
||||
: statusLabel()
|
||||
const statusTooltip = () => {
|
||||
const retryState = retry()
|
||||
if (!retryState) return undefined
|
||||
return t("sessionList.status.retryTooltip", {
|
||||
message: retryState.message,
|
||||
attempt: String(retryState.attempt),
|
||||
})
|
||||
}
|
||||
|
||||
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
|
||||
|
||||
@@ -469,7 +520,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||
</span>
|
||||
</Show>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
|
||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{statusText()}
|
||||
</span>
|
||||
@@ -491,6 +542,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => handleReloadSession(event, rowProps.sessionId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("sessionList.actions.reload.ariaLabel")}
|
||||
title={t("sessionList.actions.reload.title")}
|
||||
>
|
||||
<Show
|
||||
when={!isSessionReloading(rowProps.sessionId)}
|
||||
fallback={<RotateCw class="w-3 h-3 animate-spin" />}
|
||||
>
|
||||
<RotateCw class="w-3 h-3" />
|
||||
</Show>
|
||||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -86,6 +86,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SpeechSynthesisResponse,
|
||||
SpeechTranscriptionResponse,
|
||||
ServerMeta,
|
||||
VoiceModeStateResponse,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceDescriptor,
|
||||
WorkspaceFileResponse,
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
WorktreeMap,
|
||||
WorktreeCreateRequest,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getClientIdentity } from "./client-identity"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||
@@ -234,6 +236,16 @@ export const serverApi = {
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise<void> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ contents }),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||
@@ -338,6 +350,19 @@ export const serverApi = {
|
||||
{ method: "POST" },
|
||||
)
|
||||
},
|
||||
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
|
||||
const identity = getClientIdentity()
|
||||
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...identity, enabled }),
|
||||
})
|
||||
},
|
||||
sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }): Promise<void> {
|
||||
return request<void>("/api/client-connections/pong", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchBackgroundProcessOutput(
|
||||
instanceId: string,
|
||||
processId: string,
|
||||
@@ -362,9 +387,15 @@ export const serverApi = {
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
|
||||
)
|
||||
},
|
||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
||||
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
|
||||
connectEvents(
|
||||
onEvent: (event: WorkspaceEventPayload) => void,
|
||||
onError?: () => void,
|
||||
onPing?: (payload: { ts?: number }) => void,
|
||||
) {
|
||||
const identity = getClientIdentity()
|
||||
const url = buildClientEventsUrl(identity)
|
||||
sseLogger.info(`Connecting to ${url}`)
|
||||
const source = new EventSource(url, { withCredentials: true } as any)
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
@@ -377,8 +408,26 @@ export const serverApi = {
|
||||
sseLogger.warn("EventSource error, closing stream")
|
||||
onError?.()
|
||||
}
|
||||
source.addEventListener("codenomad.client.ping", (event: MessageEvent) => {
|
||||
try {
|
||||
const payload = event.data ? (JSON.parse(event.data) as { ts?: number }) : {}
|
||||
onPing?.(payload)
|
||||
} catch (error) {
|
||||
sseLogger.error("Failed to parse ping event", error)
|
||||
}
|
||||
})
|
||||
return source
|
||||
},
|
||||
}
|
||||
|
||||
function buildClientEventsUrl(identity: { clientId: string; connectionId: string }): string {
|
||||
const url = new URL(EVENTS_URL, typeof window !== "undefined" ? window.location.origin : "http://localhost")
|
||||
url.searchParams.set("clientId", identity.clientId)
|
||||
url.searchParams.set("connectionId", identity.connectionId)
|
||||
if (EVENTS_URL.startsWith("http://") || EVENTS_URL.startsWith("https://")) {
|
||||
return url.toString()
|
||||
}
|
||||
return `${url.pathname}${url.search}`
|
||||
}
|
||||
|
||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
|
||||
|
||||
58
packages/ui/src/lib/client-identity.ts
Normal file
58
packages/ui/src/lib/client-identity.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
const CLIENT_ID_STORAGE_KEY = "codenomad.client-id"
|
||||
const CONNECTION_ID_STORAGE_KEY = "codenomad.connection-id"
|
||||
|
||||
let cachedClientId: string | null = null
|
||||
let cachedConnectionId: string | null = null
|
||||
|
||||
export function getClientIdentity(): { clientId: string; connectionId: string } {
|
||||
return {
|
||||
clientId: getOrCreateClientId(),
|
||||
connectionId: getOrCreateConnectionId(),
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateClientId(): string {
|
||||
if (cachedClientId) return cachedClientId
|
||||
cachedClientId = getOrCreateStoredValue(CLIENT_ID_STORAGE_KEY, window.localStorage)
|
||||
return cachedClientId
|
||||
}
|
||||
|
||||
function getOrCreateConnectionId(): string {
|
||||
if (cachedConnectionId) return cachedConnectionId
|
||||
cachedConnectionId = getOrCreateStoredValue(CONNECTION_ID_STORAGE_KEY, window.sessionStorage)
|
||||
return cachedConnectionId
|
||||
}
|
||||
|
||||
function getOrCreateStoredValue(key: string, storage: Storage): string {
|
||||
if (typeof window === "undefined") {
|
||||
return generateUUID()
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = storage.getItem(key)
|
||||
if (existing && existing.trim()) {
|
||||
return existing.trim()
|
||||
}
|
||||
} catch {
|
||||
return generateUUID()
|
||||
}
|
||||
|
||||
const next = generateUUID()
|
||||
try {
|
||||
storage.setItem(key, next)
|
||||
} catch {
|
||||
// Ignore storage failures and fall back to the in-memory value.
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
|
||||
const random = (Math.random() * 16) | 0
|
||||
const value = char === "x" ? random : (random & 0x3) | 0x8
|
||||
return value.toString(16)
|
||||
})
|
||||
}
|
||||
@@ -19,9 +19,6 @@ export function formatCompactCount(value: number): string {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 10_000) {
|
||||
return `${Math.round(value / 1_000)}K`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const label = `${(value / 1_000).toFixed(1)}K`
|
||||
return label.replace(/\.0K$/, "K")
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "Sessions",
|
||||
"instanceShell.leftPanel.instanceInfo": "Instance Info",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "Pin left drawer",
|
||||
"instanceShell.leftDrawer.unpin": "Unpin left drawer",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned",
|
||||
@@ -95,6 +94,20 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.status": "Status",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
||||
"instanceShell.rightPanel.actions.refresh": "Refresh",
|
||||
"instanceShell.rightPanel.actions.save": "Save (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "Do you want to save changes to \"{path}\" before switching?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Save",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Discard Changes",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "File was modified by the agent. Overwrite agent's changes?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Overwrite",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancel",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "File has unsaved changes. Refresh will discard your edits. Continue?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Refresh",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
|
||||
"instanceShell.rightPanel.toast.saveError": "Failed to save file",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "Yolo Mode",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Automatically approves permission requests for the current session. Use it only when you trust the tools being run.",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
|
||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||
@@ -138,6 +151,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "Select a session to configure Yolo mode.",
|
||||
"instanceShell.yoloMode.title": "Yolo mode",
|
||||
"instanceShell.yoloMode.description": "Automatically approve permission requests for this session. Disabled by default.",
|
||||
"instanceShell.yoloMode.badge": "Yolo mode",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "Yolo mode enabled",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "No background processes.",
|
||||
"instanceShell.backgroundProcesses.status": "Status: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "Working",
|
||||
"sessionList.status.compacting": "Compacting",
|
||||
"sessionList.status.idle": "Idle",
|
||||
"sessionList.status.retrying": "Retrying",
|
||||
"sessionList.status.retryingIn": "Retrying in {seconds}s",
|
||||
"sessionList.status.retryTooltip": "{message} (Attempt {attempt})",
|
||||
"sessionList.status.retryToast": "{countdown}: {message} (Attempt {attempt})",
|
||||
"sessionList.status.needsPermission": "Needs Permission",
|
||||
"sessionList.status.needsInput": "Needs Input",
|
||||
"sessionList.expand.collapseAriaLabel": "Collapse session",
|
||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "New session",
|
||||
"sessionList.actions.copyId.ariaLabel": "Copy session ID",
|
||||
"sessionList.actions.copyId.title": "Copy session ID",
|
||||
"sessionList.actions.reload.ariaLabel": "Reload session",
|
||||
"sessionList.actions.reload.title": "Reload session",
|
||||
"sessionList.actions.rename.ariaLabel": "Rename session",
|
||||
"sessionList.actions.rename.title": "Rename session",
|
||||
"sessionList.actions.delete.ariaLabel": "Delete session",
|
||||
"sessionList.actions.delete.title": "Delete session",
|
||||
"sessionList.copyId.success": "Session ID copied",
|
||||
"sessionList.copyId.error": "Unable to copy session ID",
|
||||
"sessionList.reload.error": "Unable to reload session",
|
||||
"sessionList.delete.error": "Unable to delete session",
|
||||
"sessionList.delete.title": "Delete session",
|
||||
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "Sesiones",
|
||||
"instanceShell.leftPanel.instanceInfo": "Info de la instancia",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "Fijar panel izquierdo",
|
||||
"instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado",
|
||||
@@ -94,6 +93,21 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||
"instanceShell.rightPanel.actions.refresh": "Actualizar",
|
||||
"instanceShell.rightPanel.actions.save": "Guardar (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "¿Deseas guardar los cambios en \"{path}\" antes de cambiar?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Guardar",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Descartar cambios",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "El archivo fue modificado por el agente. ¿Sobrescribir los cambios del agente?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Sobrescribir",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancelar",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "El archivo tiene cambios sin guardar. Actualizar discardará tus ediciones. ¿Continuar?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualizar",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
|
||||
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "Modo yolo",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Aprueba automaticamente las solicitudes de permiso de la sesion actual. Usalo solo si confias en las herramientas que se estan ejecutando.",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
|
||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||
@@ -127,6 +141,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
|
||||
"instanceShell.plan.empty": "Aún no hay nada planificado.",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "Selecciona una sesion para configurar el modo yolo.",
|
||||
"instanceShell.yoloMode.title": "Modo yolo",
|
||||
"instanceShell.yoloMode.description": "Aprueba automaticamente las solicitudes de permiso de esta sesion. Esta desactivado por defecto.",
|
||||
"instanceShell.yoloMode.badge": "Modo yolo",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "Modo yolo activado",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
|
||||
"instanceShell.backgroundProcesses.status": "Estado: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "Trabajando",
|
||||
"sessionList.status.compacting": "Compactando",
|
||||
"sessionList.status.idle": "Inactiva",
|
||||
"sessionList.status.retrying": "Reintentando",
|
||||
"sessionList.status.retryingIn": "Reintentando en {seconds}s",
|
||||
"sessionList.status.retryTooltip": "{message} (Intento {attempt})",
|
||||
"sessionList.status.retryToast": "{countdown}: {message} (Intento {attempt})",
|
||||
"sessionList.status.needsPermission": "Requiere permiso",
|
||||
"sessionList.status.needsInput": "Requiere entrada",
|
||||
"sessionList.expand.collapseAriaLabel": "Colapsar sesión",
|
||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "Nueva sesión",
|
||||
"sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión",
|
||||
"sessionList.actions.copyId.title": "Copiar ID de sesión",
|
||||
"sessionList.actions.reload.ariaLabel": "Recargar sesión",
|
||||
"sessionList.actions.reload.title": "Recargar sesión",
|
||||
"sessionList.actions.rename.ariaLabel": "Renombrar sesión",
|
||||
"sessionList.actions.rename.title": "Renombrar sesión",
|
||||
"sessionList.actions.delete.ariaLabel": "Eliminar sesión",
|
||||
"sessionList.actions.delete.title": "Eliminar sesión",
|
||||
"sessionList.copyId.success": "ID de sesión copiado",
|
||||
"sessionList.copyId.error": "No se pudo copiar el ID de sesión",
|
||||
"sessionList.reload.error": "No se pudo recargar la sesión",
|
||||
"sessionList.delete.error": "No se pudo eliminar la sesión",
|
||||
"sessionList.delete.title": "Eliminar sesión",
|
||||
"sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.",
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "Sessions",
|
||||
"instanceShell.leftPanel.instanceInfo": "Infos de l'instance",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "Épingler le tiroir gauche",
|
||||
"instanceShell.leftDrawer.unpin": "Désépingler le tiroir gauche",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "Tiroir gauche épinglé",
|
||||
@@ -94,6 +93,21 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
||||
"instanceShell.rightPanel.tabs.status": "Statut",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
||||
"instanceShell.rightPanel.actions.refresh": "Actualiser",
|
||||
"instanceShell.rightPanel.actions.save": "Enregistrer (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "Voulez-vous enregistrer les modifications de \"{path}\" avant de changer ?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Enregistrer",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Annuler les modifications",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "Le fichier a été modifié par l'agent. Écraser les modifications de l'agent ?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Écraser",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Annuler",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "Le fichier a des modifications non enregistrées. Actualiser supprimera vos modifications. Continuer ?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualiser",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
|
||||
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "Mode yolo",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Approuve automatiquement les demandes d'autorisation pour la session actuelle. A utiliser seulement si vous faites confiance aux outils executes.",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
|
||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||
@@ -127,6 +141,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
|
||||
"instanceShell.plan.empty": "Aucun plan pour l'instant.",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "Selectionnez une session pour configurer le mode yolo.",
|
||||
"instanceShell.yoloMode.title": "Mode yolo",
|
||||
"instanceShell.yoloMode.description": "Approuve automatiquement les demandes d'autorisation pour cette session. Desactive par defaut.",
|
||||
"instanceShell.yoloMode.badge": "Mode yolo",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "Mode yolo active",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
|
||||
"instanceShell.backgroundProcesses.status": "Statut : {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "En cours",
|
||||
"sessionList.status.compacting": "Compactage",
|
||||
"sessionList.status.idle": "Inactif",
|
||||
"sessionList.status.retrying": "Nouvelle tentative",
|
||||
"sessionList.status.retryingIn": "Nouvelle tentative dans {seconds}s",
|
||||
"sessionList.status.retryTooltip": "{message} (Tentative {attempt})",
|
||||
"sessionList.status.retryToast": "{countdown} : {message} (Tentative {attempt})",
|
||||
"sessionList.status.needsPermission": "Autorisation requise",
|
||||
"sessionList.status.needsInput": "Entrée requise",
|
||||
"sessionList.expand.collapseAriaLabel": "Réduire la session",
|
||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "Nouvelle session",
|
||||
"sessionList.actions.copyId.ariaLabel": "Copier l'ID de session",
|
||||
"sessionList.actions.copyId.title": "Copier l'ID de session",
|
||||
"sessionList.actions.reload.ariaLabel": "Recharger la session",
|
||||
"sessionList.actions.reload.title": "Recharger la session",
|
||||
"sessionList.actions.rename.ariaLabel": "Renommer la session",
|
||||
"sessionList.actions.rename.title": "Renommer la session",
|
||||
"sessionList.actions.delete.ariaLabel": "Supprimer la session",
|
||||
"sessionList.actions.delete.title": "Supprimer la session",
|
||||
"sessionList.copyId.success": "ID de session copié",
|
||||
"sessionList.copyId.error": "Impossible de copier l'ID de session",
|
||||
"sessionList.reload.error": "Impossible de recharger la session",
|
||||
"sessionList.delete.error": "Impossible de supprimer la session",
|
||||
"sessionList.delete.title": "Supprimer la session",
|
||||
"sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.",
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "סשנים",
|
||||
"instanceShell.leftPanel.instanceInfo": "מידע על המופע",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
|
||||
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
|
||||
@@ -95,6 +94,20 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.status": "סטטוס",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
|
||||
"instanceShell.rightPanel.actions.refresh": "רענן",
|
||||
"instanceShell.rightPanel.actions.save": "שמור (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "האם ברצונך לשמור את השינויים לפני המעבר?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "שמור",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "בטל שינויים",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "הקובץ שונה על ידי הסוכן. לדרוס את שינויי הסוכן?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "דרוס",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "בטל",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "לקובץ יש שינויים שלא נשמרו. רענון יבטל את העריכות שלך. להמשיך?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "רענן",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה",
|
||||
"instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "מצב Yolo",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "מאשר אוטומטית בקשות הרשאה עבור הסשן הנוכחי. השתמשו בזה רק אם אתם סומכים על הכלים שרצים.",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
|
||||
"instanceShell.rightPanel.sections.plan": "תוכנית",
|
||||
@@ -136,6 +149,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
|
||||
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "בחרו סשן כדי להגדיר מצב Yolo.",
|
||||
"instanceShell.yoloMode.title": "מצב Yolo",
|
||||
"instanceShell.yoloMode.description": "מאשר אוטומטית בקשות הרשאה עבור הסשן הזה. כבוי כברירת מחדל.",
|
||||
"instanceShell.yoloMode.badge": "Yolo",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "מצב Yolo פעיל",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
|
||||
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "עובד",
|
||||
"sessionList.status.compacting": "מסכם",
|
||||
"sessionList.status.idle": "מוכן",
|
||||
"sessionList.status.retrying": "מנסה שוב",
|
||||
"sessionList.status.retryingIn": "מנסה שוב בעוד {seconds}ש׳",
|
||||
"sessionList.status.retryTooltip": "{message} (ניסיון {attempt})",
|
||||
"sessionList.status.retryToast": "{countdown}: {message} (ניסיון {attempt})",
|
||||
"sessionList.status.needsPermission": "נדרש אישור",
|
||||
"sessionList.status.needsInput": "נדרש קלט",
|
||||
"sessionList.expand.collapseAriaLabel": "כווץ סשן",
|
||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "סשן חדש",
|
||||
"sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן",
|
||||
"sessionList.actions.copyId.title": "העתק מזהה סשן",
|
||||
"sessionList.actions.reload.ariaLabel": "טען מחדש סשן",
|
||||
"sessionList.actions.reload.title": "טען מחדש סשן",
|
||||
"sessionList.actions.rename.ariaLabel": "שנה שם סשן",
|
||||
"sessionList.actions.rename.title": "שנה שם סשן",
|
||||
"sessionList.actions.delete.ariaLabel": "מחק סשן",
|
||||
"sessionList.actions.delete.title": "מחק סשן",
|
||||
"sessionList.copyId.success": "מזהה סשן הועתק",
|
||||
"sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן",
|
||||
"sessionList.reload.error": "לא ניתן לטעון מחדש את הסשן",
|
||||
"sessionList.delete.error": "לא ניתן למחוק סשן",
|
||||
"sessionList.delete.title": "מחק סשן",
|
||||
"sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.",
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "セッション",
|
||||
"instanceShell.leftPanel.instanceInfo": "インスタンス情報",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "左ドロワーを固定",
|
||||
"instanceShell.leftDrawer.unpin": "左ドロワーの固定を解除",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "左ドロワーを固定しました",
|
||||
@@ -94,6 +93,21 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "ファイル",
|
||||
"instanceShell.rightPanel.tabs.status": "ステータス",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
||||
"instanceShell.rightPanel.actions.refresh": "更新",
|
||||
"instanceShell.rightPanel.actions.save": "保存 (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "「{path}」への変更を切り替え前に保存しますか?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "保存",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "変更を破棄",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "ファイルはエージェントによって変更されました。上書きしますか?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "上書き",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "キャンセル",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "ファイルには未保存の変更があります。更新すると編集が破棄されます。続行しますか?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "更新",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "キャンセル",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "ファイルを保存しました",
|
||||
"instanceShell.rightPanel.toast.saveError": "ファイルの保存に失敗しました",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "Yoloモード",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "現在のセッションの権限リクエストを自動承認します。実行中のツールを信頼できる場合にのみ使用してください。",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
|
||||
"instanceShell.rightPanel.sections.plan": "計画",
|
||||
@@ -127,6 +141,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
|
||||
"instanceShell.plan.empty": "まだ計画はありません。",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "Yoloモードを設定するにはセッションを選択してください。",
|
||||
"instanceShell.yoloMode.title": "Yoloモード",
|
||||
"instanceShell.yoloMode.description": "このセッションの権限リクエストを自動承認します。デフォルトでは無効です。",
|
||||
"instanceShell.yoloMode.badge": "Yolo",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "Yoloモードが有効",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。",
|
||||
"instanceShell.backgroundProcesses.status": "状態: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "作業中",
|
||||
"sessionList.status.compacting": "圧縮中",
|
||||
"sessionList.status.idle": "待機中",
|
||||
"sessionList.status.retrying": "再試行中",
|
||||
"sessionList.status.retryingIn": "{seconds}秒後に再試行",
|
||||
"sessionList.status.retryTooltip": "{message}({attempt}回目)",
|
||||
"sessionList.status.retryToast": "{countdown}: {message}({attempt}回目)",
|
||||
"sessionList.status.needsPermission": "許可待ち",
|
||||
"sessionList.status.needsInput": "入力待ち",
|
||||
"sessionList.expand.collapseAriaLabel": "セッションを折りたたむ",
|
||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "新しいセッション",
|
||||
"sessionList.actions.copyId.ariaLabel": "セッション ID をコピー",
|
||||
"sessionList.actions.copyId.title": "セッション ID をコピー",
|
||||
"sessionList.actions.reload.ariaLabel": "セッションを再読み込み",
|
||||
"sessionList.actions.reload.title": "セッションを再読み込み",
|
||||
"sessionList.actions.rename.ariaLabel": "セッション名を変更",
|
||||
"sessionList.actions.rename.title": "セッション名を変更",
|
||||
"sessionList.actions.delete.ariaLabel": "セッションを削除",
|
||||
"sessionList.actions.delete.title": "セッションを削除",
|
||||
"sessionList.copyId.success": "セッション ID をコピーしました",
|
||||
"sessionList.copyId.error": "セッション ID をコピーできません",
|
||||
"sessionList.reload.error": "セッションを再読み込みできません",
|
||||
"sessionList.delete.error": "セッションを削除できません",
|
||||
"sessionList.delete.title": "セッションを削除",
|
||||
"sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。",
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "Сессии",
|
||||
"instanceShell.leftPanel.instanceInfo": "Информация об экземпляре",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "Закрепить левую панель",
|
||||
"instanceShell.leftDrawer.unpin": "Открепить левую панель",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "Левая панель закреплена",
|
||||
@@ -94,6 +93,21 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "Файлы",
|
||||
"instanceShell.rightPanel.tabs.status": "Статус",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
||||
"instanceShell.rightPanel.actions.refresh": "Обновить",
|
||||
"instanceShell.rightPanel.actions.save": "Сохранить (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "Сохранить изменения в \"{path}\" перед переключением?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Сохранить",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Отменить изменения",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "Файл был изменён агентом. Перезаписать изменения агента?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Перезаписать",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Отмена",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "Файл имеет несохранённые изменения. Обновление отменит ваши правки. Продолжить?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Обновить",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Отмена",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "Файл успешно сохранён",
|
||||
"instanceShell.rightPanel.toast.saveError": "Не удалось сохранить файл",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "Режим Yolo",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Автоматически одобряет запросы разрешений для текущей сессии. Включайте только если доверяете запускаемым инструментам.",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
|
||||
"instanceShell.rightPanel.sections.plan": "План",
|
||||
@@ -127,6 +141,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.",
|
||||
"instanceShell.plan.empty": "Пока ничего не запланировано.",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "Выберите сессию, чтобы настроить режим Yolo.",
|
||||
"instanceShell.yoloMode.title": "Режим Yolo",
|
||||
"instanceShell.yoloMode.description": "Автоматически одобряет запросы разрешений для этой сессии. По умолчанию выключен.",
|
||||
"instanceShell.yoloMode.badge": "Yolo",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "Режим Yolo включен",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.",
|
||||
"instanceShell.backgroundProcesses.status": "Статус: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "Работает",
|
||||
"sessionList.status.compacting": "Компактация",
|
||||
"sessionList.status.idle": "Простой",
|
||||
"sessionList.status.retrying": "Повтор",
|
||||
"sessionList.status.retryingIn": "Повтор через {seconds}с",
|
||||
"sessionList.status.retryTooltip": "{message} (Попытка {attempt})",
|
||||
"sessionList.status.retryToast": "{countdown}: {message} (Попытка {attempt})",
|
||||
"sessionList.status.needsPermission": "Требуется разрешение",
|
||||
"sessionList.status.needsInput": "Требуется ввод",
|
||||
"sessionList.expand.collapseAriaLabel": "Свернуть сессию",
|
||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "Новая сессия",
|
||||
"sessionList.actions.copyId.ariaLabel": "Скопировать ID сессии",
|
||||
"sessionList.actions.copyId.title": "Скопировать ID сессии",
|
||||
"sessionList.actions.reload.ariaLabel": "Обновить сессию",
|
||||
"sessionList.actions.reload.title": "Обновить сессию",
|
||||
"sessionList.actions.rename.ariaLabel": "Переименовать сессию",
|
||||
"sessionList.actions.rename.title": "Переименовать сессию",
|
||||
"sessionList.actions.delete.ariaLabel": "Удалить сессию",
|
||||
"sessionList.actions.delete.title": "Удалить сессию",
|
||||
"sessionList.copyId.success": "ID сессии скопирован",
|
||||
"sessionList.copyId.error": "Не удалось скопировать ID сессии",
|
||||
"sessionList.reload.error": "Не удалось обновить сессию",
|
||||
"sessionList.delete.error": "Не удалось удалить сессию",
|
||||
"sessionList.delete.title": "Удалить сессию",
|
||||
"sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.",
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "会话",
|
||||
"instanceShell.leftPanel.instanceInfo": "实例信息",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "固定左侧抽屉",
|
||||
"instanceShell.leftDrawer.unpin": "取消固定左侧抽屉",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "左侧抽屉已固定",
|
||||
@@ -94,6 +93,21 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.tabs.files": "文件",
|
||||
"instanceShell.rightPanel.tabs.status": "状态",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
|
||||
"instanceShell.rightPanel.actions.refresh": "刷新",
|
||||
"instanceShell.rightPanel.actions.save": "保存 (Ctrl+S)",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.message": "切换前是否保存对 \"{path}\" 的更改?",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "保存",
|
||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "放弃更改",
|
||||
"instanceShell.rightPanel.actions.conflict.message": "文件已被代理修改。是否覆盖代理的更改?",
|
||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "覆盖",
|
||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "取消",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.message": "文件有未保存的更改。刷新将放弃您的编辑。继续?",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "刷新",
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "取消",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "文件保存成功",
|
||||
"instanceShell.rightPanel.toast.saveError": "保存文件失败",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "Yolo 模式",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "自动批准当前会话的权限请求。仅在你信任正在运行的工具时启用。",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "会话更改",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
|
||||
"instanceShell.rightPanel.sections.plan": "计划",
|
||||
@@ -127,6 +141,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "选择会话以查看计划。",
|
||||
"instanceShell.plan.empty": "暂无计划。",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "请选择一个会话来配置 Yolo 模式。",
|
||||
"instanceShell.yoloMode.title": "Yolo 模式",
|
||||
"instanceShell.yoloMode.description": "自动批准此会话的权限请求。默认关闭。",
|
||||
"instanceShell.yoloMode.badge": "Yolo",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "Yolo 模式已启用",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "没有后台进程。",
|
||||
"instanceShell.backgroundProcesses.status": "状态:{status}",
|
||||
"instanceShell.backgroundProcesses.output": "输出:{sizeKb}KB",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "工作中",
|
||||
"sessionList.status.compacting": "压缩中",
|
||||
"sessionList.status.idle": "空闲",
|
||||
"sessionList.status.retrying": "重试中",
|
||||
"sessionList.status.retryingIn": "{seconds} 秒后重试",
|
||||
"sessionList.status.retryTooltip": "{message}(第 {attempt} 次尝试)",
|
||||
"sessionList.status.retryToast": "{countdown}: {message}(第 {attempt} 次尝试)",
|
||||
"sessionList.status.needsPermission": "需要权限",
|
||||
"sessionList.status.needsInput": "需要输入",
|
||||
"sessionList.expand.collapseAriaLabel": "折叠会话",
|
||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
||||
"sessionList.actions.newSession.title": "新建会话",
|
||||
"sessionList.actions.copyId.ariaLabel": "复制会话 ID",
|
||||
"sessionList.actions.copyId.title": "复制会话 ID",
|
||||
"sessionList.actions.reload.ariaLabel": "重新加载会话",
|
||||
"sessionList.actions.reload.title": "重新加载会话",
|
||||
"sessionList.actions.rename.ariaLabel": "重命名会话",
|
||||
"sessionList.actions.rename.title": "重命名会话",
|
||||
"sessionList.actions.delete.ariaLabel": "删除会话",
|
||||
"sessionList.actions.delete.title": "删除会话",
|
||||
"sessionList.copyId.success": "已复制会话 ID",
|
||||
"sessionList.copyId.error": "无法复制会话 ID",
|
||||
"sessionList.reload.error": "无法重新加载会话",
|
||||
"sessionList.delete.error": "无法删除会话",
|
||||
"sessionList.delete.title": "删除会话",
|
||||
"sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。",
|
||||
|
||||
@@ -11,6 +11,7 @@ let highlighterPromise: Promise<Highlighter> | null = null
|
||||
let currentTheme: "light" | "dark" = "light"
|
||||
let isInitialized = false
|
||||
let highlightSuppressed = false
|
||||
let escapeRawHtmlEnabled = false
|
||||
let rendererSetup = false
|
||||
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
|
||||
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
|
||||
@@ -119,14 +120,7 @@ function resolveLanguage(token: string): { canonical: string | null; raw: string
|
||||
return { canonical: null, raw: normalized }
|
||||
}
|
||||
|
||||
async function ensureLanguages(content: string) {
|
||||
if (highlightSuppressed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract code-fence language tokens via `marked` so we correctly handle code blocks
|
||||
// that contain backticks (e.g. JS template literals). Regex-based fence scans tend
|
||||
// to miss these and prevent languages from loading.
|
||||
function collectCodeFenceLanguages(content: string): string[] {
|
||||
const foundLanguages = new Set<string>()
|
||||
try {
|
||||
const tokens = marked.lexer(content) as any
|
||||
@@ -138,10 +132,44 @@ async function ensureLanguages(content: string) {
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// If tokenization fails for any reason, skip language preloading.
|
||||
return []
|
||||
}
|
||||
|
||||
return [...foundLanguages]
|
||||
}
|
||||
|
||||
export function hasPendingCodeHighlight(content: string): boolean {
|
||||
const languages = collectCodeFenceLanguages(content)
|
||||
for (const token of languages) {
|
||||
const rawToken = normalizeLanguageToken(token)
|
||||
if (!rawToken || rawToken === "text") {
|
||||
continue
|
||||
}
|
||||
|
||||
const { canonical, raw } = resolveLanguage(token)
|
||||
const langKey = canonical || raw
|
||||
if (langKey === "text" || raw === "text") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!highlighter || !loadedLanguages.has(langKey)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function ensureLanguages(content: string) {
|
||||
if (highlightSuppressed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract code-fence language tokens via `marked` so we correctly handle code blocks
|
||||
// that contain backticks (e.g. JS template literals). Regex-based fence scans tend
|
||||
// to miss these and prevent languages from loading.
|
||||
const foundLanguages = collectCodeFenceLanguages(content)
|
||||
|
||||
// Queue language loading tasks
|
||||
for (const token of foundLanguages) {
|
||||
const rawToken = normalizeLanguageToken(token)
|
||||
@@ -285,6 +313,14 @@ function setupRenderer(isDark: boolean) {
|
||||
return `<code class="inline-code">${escapeHtml(decoded)}</code>`
|
||||
}
|
||||
|
||||
renderer.html = (html: string) => {
|
||||
if (!escapeRawHtmlEnabled) {
|
||||
return html
|
||||
}
|
||||
|
||||
return escapeHtml(decodeHtmlEntities(html))
|
||||
}
|
||||
|
||||
marked.use({ renderer })
|
||||
rendererSetup = true
|
||||
}
|
||||
@@ -308,6 +344,7 @@ export async function renderMarkdown(
|
||||
content: string,
|
||||
options?: {
|
||||
suppressHighlight?: boolean
|
||||
escapeRawHtml?: boolean
|
||||
},
|
||||
): Promise<string> {
|
||||
if (!isInitialized) {
|
||||
@@ -316,6 +353,7 @@ export async function renderMarkdown(
|
||||
}
|
||||
|
||||
const suppressHighlight = options?.suppressHighlight ?? false
|
||||
const escapeRawHtml = options?.escapeRawHtml ?? false
|
||||
const decoded = decodeHtmlEntities(content)
|
||||
|
||||
if (!suppressHighlight) {
|
||||
@@ -324,13 +362,16 @@ export async function renderMarkdown(
|
||||
}
|
||||
|
||||
const previousSuppressed = highlightSuppressed
|
||||
const previousEscapeRawHtml = escapeRawHtmlEnabled
|
||||
highlightSuppressed = suppressHighlight
|
||||
escapeRawHtmlEnabled = escapeRawHtml
|
||||
|
||||
try {
|
||||
// Proceed to parse immediately - highlighting will be available on next render
|
||||
return marked.parse(decoded) as Promise<string>
|
||||
} finally {
|
||||
highlightSuppressed = previousSuppressed
|
||||
escapeRawHtmlEnabled = previousEscapeRawHtml
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,9 +102,11 @@ export function showToastNotification(payload: ToastPayload): ToastHandle {
|
||||
</button>
|
||||
<div class="flex items-start gap-3 pr-6">
|
||||
<span class={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${accent.badge}`} />
|
||||
<div class="flex-1 text-sm leading-snug">
|
||||
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>}
|
||||
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p>
|
||||
<div class="min-w-0 flex-1 text-sm leading-snug">
|
||||
{payload.title && <p class={`break-words ${accent.headline} font-semibold`}>{payload.title}</p>}
|
||||
<p class={`${accent.body} ${payload.title ? "mt-1" : ""} whitespace-pre-wrap break-words [overflow-wrap:anywhere]`}>
|
||||
{payload.message}
|
||||
</p>
|
||||
{payload.action && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
import { getClientIdentity } from "./client-identity"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const RETRY_BASE_DELAY = 1000
|
||||
@@ -16,6 +17,7 @@ function logSse(message: string, context?: Record<string, unknown>) {
|
||||
|
||||
class ServerEvents {
|
||||
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
|
||||
private openHandlers = new Set<() => void>()
|
||||
private source: EventSource | null = null
|
||||
private retryDelay = RETRY_BASE_DELAY
|
||||
|
||||
@@ -28,10 +30,24 @@ class ServerEvents {
|
||||
this.source.close()
|
||||
}
|
||||
logSse("Connecting to backend events stream")
|
||||
this.source = serverApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
|
||||
this.source = serverApi.connectEvents(
|
||||
(event) => this.dispatch(event),
|
||||
() => this.scheduleReconnect(),
|
||||
(payload) => {
|
||||
void serverApi
|
||||
.sendClientConnectionPong({
|
||||
...getClientIdentity(),
|
||||
pingTs: payload.ts,
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("Failed to send client connection pong", error)
|
||||
})
|
||||
},
|
||||
)
|
||||
this.source.onopen = () => {
|
||||
logSse("Events stream connected")
|
||||
this.retryDelay = RETRY_BASE_DELAY
|
||||
this.openHandlers.forEach((handler) => handler())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +77,11 @@ class ServerEvents {
|
||||
bucket.add(handler)
|
||||
return () => bucket.delete(handler)
|
||||
}
|
||||
|
||||
onOpen(handler: () => void): () => void {
|
||||
this.openHandlers.add(handler)
|
||||
return () => this.openHandlers.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
export const serverEvents = new ServerEvents()
|
||||
|
||||
@@ -10,6 +10,8 @@ export type AlertDialogState = {
|
||||
variant?: AlertVariant
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
/** When false, prevents dismissal via Escape key or backdrop click. Default: true */
|
||||
dismissible?: boolean
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { showToastNotification } from "../lib/notifications"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { formatToMimeType, getSpeechPlaybackSupport } from "../lib/speech-playback-support"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import { serverSettings } from "./preferences"
|
||||
import { loadSpeechCapabilities, speechCapabilities } from "./speech"
|
||||
import { getActiveSession, sessions } from "./session-state"
|
||||
@@ -30,6 +31,7 @@ interface PlaybackHandle {
|
||||
|
||||
const log = getLogger("actions")
|
||||
const [conversationModeInstances, setConversationModeInstances] = createSignal<Map<string, boolean>>(new Map())
|
||||
const LEADING_SPOKEN_BLOCK_REGEX = /^\s*```spoken[ \t]*\r?\n([\s\S]*?)\r?\n```(?:\r?\n|$)/i
|
||||
|
||||
const queuedKeys = new Set<string>()
|
||||
const spokenKeysBySession = new Map<string, Set<string>>()
|
||||
@@ -43,6 +45,10 @@ let currentPlayback:
|
||||
let queueRunner: Promise<void> | null = null
|
||||
let playbackErrorShown = false
|
||||
|
||||
serverEvents.onOpen(() => {
|
||||
void syncConversationModesToServer()
|
||||
})
|
||||
|
||||
function getEntryKey(instanceId: string, sessionId: string, messageId: string, partId: string): string {
|
||||
return `${instanceId}:${sessionId}:${messageId}:${partId}`
|
||||
}
|
||||
@@ -107,6 +113,9 @@ export function canUseConversationMode(): boolean {
|
||||
}
|
||||
|
||||
export function setConversationModeEnabled(instanceId: string, enabled: boolean): void {
|
||||
const previous = isConversationModeEnabled(instanceId)
|
||||
if (previous === enabled) return
|
||||
|
||||
setConversationModeInstances((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (enabled) {
|
||||
@@ -120,6 +129,23 @@ export function setConversationModeEnabled(instanceId: string, enabled: boolean)
|
||||
if (!enabled) {
|
||||
clearConversationPlaybackForInstance(instanceId)
|
||||
}
|
||||
|
||||
void serverApi.updateVoiceMode(instanceId, enabled).catch((error) => {
|
||||
log.error("Failed to update conversation mode", error)
|
||||
setConversationModeInstances((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (previous) {
|
||||
next.set(instanceId, true)
|
||||
} else {
|
||||
next.delete(instanceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
if (!previous) {
|
||||
clearConversationPlaybackForInstance(instanceId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleConversationMode(instanceId: string): void {
|
||||
@@ -188,7 +214,7 @@ export function handleConversationAssistantPartUpdated(instanceId: string, part:
|
||||
if (!isConversationModeEnabled(instanceId)) return
|
||||
if (!isSpeakableSession(instanceId, sessionId)) return
|
||||
|
||||
const text = resolveTextPartContent(part).trim()
|
||||
const text = extractLeadingSpokenBlock(resolveTextPartContent(part))
|
||||
if (!text) return
|
||||
|
||||
const key = getEntryKey(instanceId, sessionId, messageId, partId)
|
||||
@@ -505,3 +531,18 @@ function createObjectUrlFromBase64(audioBase64: string, mimeType: string): strin
|
||||
}
|
||||
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
|
||||
}
|
||||
|
||||
function extractLeadingSpokenBlock(text: string): string {
|
||||
const match = text.match(LEADING_SPOKEN_BLOCK_REGEX)
|
||||
if (!match?.[1]) return ""
|
||||
return match[1].trim()
|
||||
}
|
||||
|
||||
async function syncConversationModesToServer(): Promise<void> {
|
||||
const updates: Promise<unknown>[] = []
|
||||
for (const [instanceId, enabled] of conversationModeInstances()) {
|
||||
if (!enabled) continue
|
||||
updates.push(serverApi.updateVoiceMode(instanceId, true))
|
||||
}
|
||||
await Promise.allSettled(updates)
|
||||
}
|
||||
|
||||
81
packages/ui/src/stores/permission-auto-accept.ts
Normal file
81
packages/ui/src/stores/permission-auto-accept.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const STORAGE_KEY = "codenomad:permission-auto-accept:v1"
|
||||
|
||||
function makeKey(instanceId: string, sessionId: string) {
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
function readInitialState() {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return new Map<string, boolean>()
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return new Map<string, boolean>()
|
||||
const parsed = JSON.parse(raw) as Record<string, boolean>
|
||||
return new Map(Object.entries(parsed).filter((entry): entry is [string, boolean] => entry[1] === true))
|
||||
} catch {
|
||||
return new Map<string, boolean>()
|
||||
}
|
||||
}
|
||||
|
||||
function persist(next: Map<string, boolean>) {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(next)))
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
|
||||
const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState())
|
||||
const [inFlightVersion, setInFlightVersion] = createSignal(0)
|
||||
|
||||
const inFlight = new Set<string>()
|
||||
|
||||
export function isPermissionAutoAcceptEnabled(instanceId: string, sessionId: string) {
|
||||
return autoAcceptState().get(makeKey(instanceId, sessionId)) ?? false
|
||||
}
|
||||
|
||||
export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: string, enabled: boolean) {
|
||||
const key = makeKey(instanceId, sessionId)
|
||||
setAutoAcceptState((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (enabled) {
|
||||
next.set(key, true)
|
||||
} else {
|
||||
next.delete(key)
|
||||
}
|
||||
persist(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function togglePermissionAutoAccept(instanceId: string, sessionId: string) {
|
||||
setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId))
|
||||
}
|
||||
|
||||
export function canAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) {
|
||||
const key = makeKey(instanceId, sessionId)
|
||||
if (!autoAcceptState().get(key)) return false
|
||||
const requestKey = `${key}:${requestId}`
|
||||
if (inFlight.has(requestKey)) return false
|
||||
inFlight.add(requestKey)
|
||||
return true
|
||||
}
|
||||
|
||||
export function getPermissionAutoAcceptInFlightVersion() {
|
||||
return inFlightVersion()
|
||||
}
|
||||
|
||||
export function finishAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) {
|
||||
if (!inFlight.delete(`${makeKey(instanceId, sessionId)}:${requestId}`)) {
|
||||
return
|
||||
}
|
||||
setInFlightVersion((value) => value + 1)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
||||
import { mapSdkSessionRetry, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
@@ -149,12 +149,15 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
const existingStatus = existingSession?.status
|
||||
|
||||
let status: SessionStatus
|
||||
let retry = existingSession?.retry ?? null
|
||||
if (existingStatus === "compacting") {
|
||||
status = "compacting"
|
||||
retry = null
|
||||
} else {
|
||||
const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id]
|
||||
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
|
||||
status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle"
|
||||
retry = hasType ? mapSdkSessionRetry(rawStatus) : retry
|
||||
}
|
||||
|
||||
sessionMap.set(apiSession.id, {
|
||||
@@ -165,6 +168,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
agent: existingSession?.agent ?? "",
|
||||
model: existingSession?.model ?? { providerId: "", modelId: "" },
|
||||
status,
|
||||
retry,
|
||||
version: apiSession.version,
|
||||
time: {
|
||||
...apiSession.time,
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "
|
||||
import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question"
|
||||
import type { QuestionRequest } from "../types/question"
|
||||
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { showToastNotification, type ToastHandle, ToastVariant } from "../lib/notifications"
|
||||
import { sendOsNotification } from "../lib/os-notifications"
|
||||
import { preferences } from "./preferences"
|
||||
import {
|
||||
@@ -39,7 +39,14 @@ import {
|
||||
removeQuestionFromQueue,
|
||||
} from "./instances"
|
||||
import { showAlertDialog } from "./alerts"
|
||||
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
||||
import {
|
||||
createClientSession,
|
||||
mapSdkSessionRetry,
|
||||
mapSdkSessionStatus,
|
||||
type Session,
|
||||
type SessionRetryState,
|
||||
type SessionStatus,
|
||||
} from "../types/session"
|
||||
import { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
@@ -67,6 +74,15 @@ import { handleConversationAssistantPartUpdated } from "./conversation-speech"
|
||||
|
||||
const log = getLogger("sse")
|
||||
const pendingSessionFetches = new Map<string, Promise<void>>()
|
||||
let activeRetryToast: ToastHandle | null = null
|
||||
|
||||
function isSameRetryState(left: SessionRetryState | null | undefined, right: SessionRetryState | null | undefined): boolean {
|
||||
const a = left ?? null
|
||||
const b = right ?? null
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
return a.attempt === b.attempt && a.message === b.message && a.next === b.next
|
||||
}
|
||||
|
||||
function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
|
||||
if (typeof document === "undefined") return false
|
||||
@@ -131,18 +147,20 @@ interface TuiToastEvent {
|
||||
|
||||
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
|
||||
|
||||
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
|
||||
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus, retry?: SessionRetryState | null) {
|
||||
let parentToExpand: string | null = null
|
||||
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
const current = session.status ?? "idle"
|
||||
if (current === status) return false
|
||||
const nextRetry = retry ?? null
|
||||
if (current === status && isSameRetryState(session.retry, nextRetry)) return false
|
||||
|
||||
if (current === "compacting" && status !== "compacting") {
|
||||
return false
|
||||
}
|
||||
|
||||
session.status = status
|
||||
session.retry = status === "working" ? nextRetry : null
|
||||
|
||||
// Auto-expand the parent thread when a child session starts working.
|
||||
// Users can still collapse it; we only expand on the transition.
|
||||
@@ -172,6 +190,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
|
||||
)
|
||||
|
||||
let fetchedStatus: SessionStatus = "idle"
|
||||
let fetchedRetry: SessionRetryState | null = null
|
||||
try {
|
||||
let statuses: Record<string, any> = {}
|
||||
try {
|
||||
@@ -187,11 +206,13 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
|
||||
const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
|
||||
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
|
||||
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
|
||||
fetchedRetry = hasType ? mapSdkSessionRetry(rawStatus) : null
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch session status", error)
|
||||
}
|
||||
|
||||
const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus)
|
||||
fetched.retry = fetchedRetry
|
||||
|
||||
let updatedInstanceSessions: Map<string, Session> | undefined
|
||||
let shouldExpandParent: string | null = null
|
||||
@@ -205,6 +226,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
|
||||
agent: existing?.agent ?? fetched.agent,
|
||||
model: existing?.model ?? fetched.model,
|
||||
status: existing?.status === "compacting" ? "compacting" : fetched.status,
|
||||
retry: existing?.status === "compacting" ? null : fetched.retry,
|
||||
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
|
||||
pendingQuestion: existing?.pendingQuestion ?? false,
|
||||
}
|
||||
@@ -231,14 +253,20 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus, directory?: string) {
|
||||
function ensureSessionStatus(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
status: SessionStatus,
|
||||
directory?: string,
|
||||
retry?: SessionRetryState | null,
|
||||
) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const existing = instanceSessions?.get(sessionId)
|
||||
if (existing) {
|
||||
if ((existing.status ?? "idle") === status) {
|
||||
if ((existing.status ?? "idle") === status && isSameRetryState(existing.retry, retry)) {
|
||||
return
|
||||
}
|
||||
applySessionStatus(instanceId, sessionId, status)
|
||||
applySessionStatus(instanceId, sessionId, status, retry)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -250,7 +278,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
|
||||
const pending = (async () => {
|
||||
const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
|
||||
if (!fetched) return
|
||||
applySessionStatus(instanceId, sessionId, status)
|
||||
applySessionStatus(instanceId, sessionId, status, retry)
|
||||
})()
|
||||
|
||||
pendingSessionFetches.set(key, pending)
|
||||
@@ -428,6 +456,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
modelId: "",
|
||||
},
|
||||
status: "idle",
|
||||
retry: null,
|
||||
version: info.version || "0",
|
||||
time: info.time
|
||||
? { ...info.time }
|
||||
@@ -461,6 +490,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
...existingSession,
|
||||
title: info.title || existingSession.title,
|
||||
status: existingSession.status ?? "idle",
|
||||
retry: existingSession.retry ?? null,
|
||||
time: mergedTime,
|
||||
revert: info.revert
|
||||
? {
|
||||
@@ -532,8 +562,29 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
const status = mapSdkSessionStatus(event.properties.status)
|
||||
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory)
|
||||
const rawStatus = event.properties.status
|
||||
const status = mapSdkSessionStatus(rawStatus)
|
||||
const retry = mapSdkSessionRetry(rawStatus)
|
||||
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory, retry)
|
||||
if (retry) {
|
||||
const remainingSeconds = Math.max(0, Math.round((retry.next - Date.now()) / 1000))
|
||||
const countdown =
|
||||
remainingSeconds > 0
|
||||
? tGlobal("sessionList.status.retryingIn", { seconds: String(remainingSeconds) })
|
||||
: tGlobal("sessionList.status.retrying")
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
activeRetryToast?.dismiss()
|
||||
activeRetryToast = showToastNotification({
|
||||
title: label || getInstanceDisplayName(instanceId),
|
||||
message: tGlobal("sessionList.status.retryToast", {
|
||||
countdown,
|
||||
message: retry.message,
|
||||
attempt: String(retry.attempt),
|
||||
}),
|
||||
variant: "error",
|
||||
duration: 7000,
|
||||
})
|
||||
}
|
||||
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
|
||||
}
|
||||
|
||||
@@ -547,6 +598,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
||||
if (existing) {
|
||||
withSession(instanceId, sessionID, (session) => {
|
||||
session.status = "working"
|
||||
session.retry = null
|
||||
})
|
||||
} else {
|
||||
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)
|
||||
|
||||
@@ -353,6 +353,9 @@ function setSessionStatus(instanceId: string, sessionId: string, status: Session
|
||||
if (session.status === status) return false
|
||||
const previous = session.status
|
||||
session.status = status
|
||||
if (status !== "working") {
|
||||
session.retry = null
|
||||
}
|
||||
|
||||
// If a child session starts working, auto-expand its parent thread once.
|
||||
// Users can still collapse it afterwards; we only expand on the transition.
|
||||
@@ -673,6 +676,7 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin
|
||||
detail: tGlobal("sessionState.cleanup.deepConfirm.detail"),
|
||||
confirmLabel: tGlobal("sessionState.cleanup.deepConfirm.confirmLabel"),
|
||||
cancelLabel: tGlobal("sessionState.cleanup.deepConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
}
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import type { Session, SessionRetryState, SessionStatus } from "../types/session"
|
||||
import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
|
||||
|
||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||
@@ -14,6 +14,15 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
|
||||
return session.status ?? "idle"
|
||||
}
|
||||
|
||||
export function getSessionRetry(instanceId: string, sessionId: string): SessionRetryState | null {
|
||||
const session = getSession(instanceId, sessionId)
|
||||
return session?.retry ?? null
|
||||
}
|
||||
|
||||
export function getRetrySeconds(next: number, now = Date.now()): number {
|
||||
return Math.max(0, Math.round((next - now) / 1000))
|
||||
}
|
||||
|
||||
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
|
||||
|
||||
export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus {
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working,
|
||||
.status-indicator.session-status.session-retrying,
|
||||
.status-indicator.session-status.session-compacting,
|
||||
.status-indicator.session-status.session-idle {
|
||||
font-weight: var(--font-weight-medium);
|
||||
@@ -194,6 +195,11 @@
|
||||
--session-status-dot: var(--session-status-working-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-retrying {
|
||||
color: var(--status-error);
|
||||
--session-status-dot: var(--status-error);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting {
|
||||
color: var(--session-status-compacting-fg);
|
||||
--session-status-dot: var(--session-status-compacting-fg);
|
||||
@@ -222,6 +228,10 @@
|
||||
background-color: var(--session-status-working-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-retrying.session-status-list {
|
||||
background-color: var(--status-error-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting.session-status-list {
|
||||
background-color: var(--session-status-compacting-bg);
|
||||
}
|
||||
|
||||
@@ -412,6 +412,19 @@
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.right-panel-accordion-header-row {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.right-panel-accordion-header-row .right-panel-accordion-trigger {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.right-panel-accordion-header-row .section-info-trigger {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-end: 0.75rem;
|
||||
}
|
||||
|
||||
.right-panel-accordion-trigger {
|
||||
@apply w-full flex items-center justify-between px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150;
|
||||
color: var(--text-secondary);
|
||||
@@ -452,6 +465,8 @@
|
||||
@apply inline-flex items-center justify-center p-0.5 rounded transition-all duration-150;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.section-info-trigger:hover {
|
||||
@@ -459,6 +474,12 @@
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.section-info-trigger:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin-inline-start: 2px;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,28 @@
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-base);
|
||||
min-height: 2rem;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.session-sidebar-toggle-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.session-sidebar-controls .selector-trigger,
|
||||
.session-sidebar-controls [data-model-selector-control],
|
||||
.session-sidebar-controls .selector-trigger-label,
|
||||
@@ -394,6 +416,7 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working,
|
||||
.status-indicator.session-status.session-retrying,
|
||||
.status-indicator.session-status.session-compacting,
|
||||
.status-indicator.session-status.session-idle {
|
||||
font-weight: var(--font-weight-medium);
|
||||
@@ -404,6 +427,11 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
--session-status-dot: var(--session-status-working-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-retrying {
|
||||
color: var(--status-error);
|
||||
--session-status-dot: var(--status-error);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting {
|
||||
color: var(--session-status-compacting-fg);
|
||||
--session-status-dot: var(--session-status-compacting-fg);
|
||||
@@ -432,6 +460,10 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
background-color: var(--session-status-working-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-retrying.session-status-list {
|
||||
background-color: var(--status-error-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting.session-status-list {
|
||||
background-color: var(--session-status-compacting-bg);
|
||||
}
|
||||
@@ -458,6 +490,16 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-indicator.session-yolo-mode {
|
||||
color: var(--accent-primary);
|
||||
background-color: color-mix(in oklab, var(--accent-primary) 14%, transparent);
|
||||
border-color: color-mix(in oklab, var(--accent-primary) 28%, transparent);
|
||||
}
|
||||
|
||||
.status-indicator.session-yolo-mode .status-dot {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.session-list-container {
|
||||
min-width: 200px;
|
||||
|
||||
@@ -17,6 +17,12 @@ export type {
|
||||
|
||||
export type SessionStatus = "idle" | "working" | "compacting"
|
||||
|
||||
export interface SessionRetryState {
|
||||
attempt: number
|
||||
message: string
|
||||
next: number
|
||||
}
|
||||
|
||||
export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus {
|
||||
if (!status || status.type === "idle") {
|
||||
return "idle"
|
||||
@@ -26,6 +32,18 @@ export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined)
|
||||
return "working"
|
||||
}
|
||||
|
||||
export function mapSdkSessionRetry(status: SDKSessionStatus | null | undefined): SessionRetryState | null {
|
||||
if (!status || status.type !== "retry") {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
attempt: typeof status.attempt === "number" ? status.attempt : 1,
|
||||
message: typeof status.message === "string" ? status.message : "",
|
||||
next: typeof status.next === "number" ? status.next : Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Our client-specific Session interface extending SDK Session
|
||||
export interface Session
|
||||
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
|
||||
@@ -40,6 +58,7 @@ export interface Session
|
||||
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
||||
pendingQuestion?: boolean // Indicates if session is waiting on user input
|
||||
status: SessionStatus // Single source of truth for session status
|
||||
retry?: SessionRetryState | null // Retry metadata for transient backoff states
|
||||
diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff)
|
||||
}
|
||||
|
||||
|
||||
40
scripts/bump-version.js
Normal file
40
scripts/bump-version.js
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawnSync } = require("child_process")
|
||||
|
||||
const versionArgs = process.argv.slice(2)
|
||||
|
||||
if (versionArgs.length === 0) {
|
||||
console.error("[bumpVersion] missing version argument (example: npm run bumpVersion -- patch)")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
|
||||
function runStep(args, label) {
|
||||
const result = spawnSync(npmCommand, args, {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[bumpVersion] failed during ${label}: ${result.error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
runStep(
|
||||
[
|
||||
"version",
|
||||
...versionArgs,
|
||||
"--workspaces",
|
||||
"--include-workspace-root",
|
||||
"--no-git-tag-version",
|
||||
],
|
||||
"npm version"
|
||||
)
|
||||
|
||||
runStep(["run", "sync:version", "--workspace", "@codenomad/tauri-app"], "tauri version sync")
|
||||
Reference in New Issue
Block a user