Compare commits

..

13 Commits

Author SHA1 Message Date
Shantur Rathore
9d6a5bcdc0 Refresh README with modern marketing-focused layout 2026-03-31 22:53:58 +01:00
Shantur Rathore
514b187b00 Update Worker caching 2026-03-31 22:51:04 +01:00
Shantur Rathore
240acb7729 Update README 2026-03-31 22:50:40 +01:00
Shantur Rathore
0af79002ed Min version 0.13.3 2026-03-31 20:16:35 +01:00
Shantur Rathore
f3981a1cce Bump version to 0.13.3 2026-03-31 20:15:25 +01:00
Shantur Rathore
031e8d5717 Fix bumpVersion script for both npm and tauri 2026-03-31 20:15:16 +01:00
Shantur
995fb3b6a3 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-31 19:35:28 +01:00
Shantur
aeb0ff11b3 fix(ui): stop conversation speech when voice input starts 2026-03-31 18:59:52 +01:00
Shantur
b61cfbd9f9 fix(ui): refine GitHub stars display 2026-03-31 18:51:53 +01:00
Shantur
481dd1a88a fix(ui): wrap long toast messages
Constrain toast titles and bodies so long retry and error messages wrap inside the notification card instead of overflowing past the container.
2026-03-31 18:41:32 +01:00
Shantur
3f6cdd36f3 feat(ui): surface retrying session status
Preserve retry metadata from session.status events so the session list and header can show a live retry countdown with context. Notify users when a session enters retry and reuse the existing error styling so retrying feels actionable without losing the current badge layout.
2026-03-31 18:38:54 +01:00
Shantur
fe932c8307 fix(ui): avoid caching incomplete code highlighting
Only cache markdown HTML after Shiki has the required fence languages loaded so virtualized assistant messages can re-render with syntax highlighting when remounted.
2026-03-31 15:18:44 +01:00
Pascal André
64ac885157 feat(ui): add session yolo mode controls (#256)
## Summary
- add a per-session Yolo mode toggle for permission prompts and persist
its state
- move the control into the Status tab with clearer copy, an info
tooltip, and a visible header badge when it is enabled
- auto-accept queued permissions for any yolo-enabled session in the
instance, not only the currently focused session

## Why
- keeps this risky mode explicit and easy to audit from the session
status area
- matches the expected multi-session desktop behavior when several
sessions stay active in parallel

## Testing
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui

Closes #18
2026-03-31 14:46:20 +01:00
53 changed files with 861 additions and 304 deletions

171
README.md
View File

@@ -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.
![Multi-instance workspace](docs/screenshots/newSession.png)
_Manage multiple OpenCode sessions side-by-side._
<details>
<summary>📸 More Screenshots</summary>
---
![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
## Features
![Image Previews](docs/screenshots/image-previews.png)
_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](docs/screenshots/browser-support.png)
_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
```
[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](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
[![Star History](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](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
View File

@@ -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",

View File

@@ -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 && npm run sync:version --workspace @codenomad/tauri-app"
"bumpVersion": "node ./scripts/bump-version.js"
},
"dependencies": {
"7zip-bin": "^5.2.0",

View File

@@ -1,4 +1,4 @@
{
"minServerVersion": "0.13.1",
"minServerVersion": "0.13.3",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -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)
},
}

View File

@@ -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": {

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.3.2"
"@opencode-ai/plugin": "1.3.7"
}
}

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.13.1",
"version": "0.13.3",
"description": "CodeNomad Server",
"license": "MIT",
"author": {

View File

@@ -458,7 +458,7 @@ dependencies = [
[[package]]
name = "codenomad-tauri"
version = "0.12.3"
version = "0.13.3"
dependencies = [
"anyhow",
"dirs 5.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.13.1",
"version": "0.13.3",
"private": true,
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
[package]
name = "codenomad-tauri"
version = "0.12.3"
version = "0.13.3"
edition = "2021"
license = "MIT"

View File

@@ -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"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.13.1",
"version": "0.13.3",
"private": true,
"license": "MIT",
"type": "module",

View File

@@ -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")

View File

@@ -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)
}
@@ -622,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">
@@ -719,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>

View File

@@ -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

View File

@@ -89,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",
@@ -787,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())

View File

@@ -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>

View File

@@ -123,7 +123,11 @@ export function Markdown(props: MarkdownProps) {
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,
@@ -131,7 +135,9 @@ export function Markdown(props: MarkdownProps) {
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
}
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
if (options?.cache ?? true) {
cacheHandle.set(cacheEntry)
}
notifyRendered()
}
@@ -142,9 +148,10 @@ export function Markdown(props: MarkdownProps) {
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 })
}
}

View File

@@ -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

View File

@@ -1,7 +1,7 @@
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 { 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"
@@ -55,6 +55,13 @@ const SessionList: Component<SessionListProps> = (props) => {
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)
@@ -400,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")
@@ -413,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)
@@ -499,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>

View File

@@ -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")
}

View File

@@ -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",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"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",
@@ -150,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",

View File

@@ -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",

View File

@@ -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",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"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",
@@ -140,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",

View File

@@ -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",

View File

@@ -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é",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"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",
@@ -140,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",

View File

@@ -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",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "סשנים",
"instanceShell.leftPanel.instanceInfo": "מידע על המופע",
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"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": "תוכנית",
@@ -148,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",

View File

@@ -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": "כווץ סשן",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "セッション",
"instanceShell.leftPanel.instanceInfo": "インスタンス情報",
"instanceShell.leftDrawer.pin": "左ドロワーを固定",
"instanceShell.leftDrawer.unpin": "左ドロワーの固定を解除",
"instanceShell.leftDrawer.toggle.pinned": "左ドロワーを固定しました",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"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": "計画",
@@ -140,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",

View File

@@ -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": "セッションを折りたたむ",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Сессии",
"instanceShell.leftPanel.instanceInfo": "Информация об экземпляре",
"instanceShell.leftDrawer.pin": "Закрепить левую панель",
"instanceShell.leftDrawer.unpin": "Открепить левую панель",
"instanceShell.leftDrawer.toggle.pinned": "Левая панель закреплена",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"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": "План",
@@ -140,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",

View File

@@ -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": "Свернуть сессию",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "会话",
"instanceShell.leftPanel.instanceInfo": "实例信息",
"instanceShell.leftDrawer.pin": "固定左侧抽屉",
"instanceShell.leftDrawer.unpin": "取消固定左侧抽屉",
"instanceShell.leftDrawer.toggle.pinned": "左侧抽屉已固定",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"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": "计划",
@@ -140,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",

View File

@@ -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": "折叠会话",

View File

@@ -120,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
@@ -139,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)

View File

@@ -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"

View 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)
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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
View 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")