Compare commits

...

12 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
41 changed files with 474 additions and 180 deletions

171
README.md
View File

@@ -1,128 +1,127 @@
# CodeNomad # 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) ![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) ## Features
_Global command palette for keyboard-first control._
![Image Previews](docs/screenshots/image-previews.png) - **🚀 Multi-Instance Workspace**
_Rich media previews for images and assets._ - **🌐 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 ## Getting Started
Choose the way that fits your workflow: ### 🖥️ Desktop App
### 🖥️ Desktop App (Recommended) Available as both Electron and Tauri builds — choose based on your preference.
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases). Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
- **Run**: Install and launch like any other app.
### 🦀 Tauri App (Experimental) | Platform | Formats |
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development. |----------|---------|
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases). | Windows | NSIS Installer, ZIP (x64, ARM64) |
- **Source**: Check out `packages/tauri-app` if you're interested in contributing. | Linux | AppImage, deb, tar.gz (x64, ARM64) |
### 💻 CodeNomad Server ### 💻 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 ```bash
npx @neuralnomads/codenomad --launch npx @neuralnomads/codenomad --launch
``` ```
Full server/CLI documentation (flags + env vars, TLS, auth, remote access): See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
- [packages/server/README.md](packages/server/README.md)
To see all available options:
```bash
npx @neuralnomads/codenomad --help
```
### 🧪 Dev Releases ### 🧪 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 ```bash
npx @neuralnomads/codenomad-dev --launch 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 ## Requirements
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`. - **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
- **Node.js 18+**: Required if running the CLI server or building from source. - **Node.js 18+** — for server mode or building from source
## Troubleshooting ---
### macOS says the app is damaged ## Development
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`:
```bash CodeNomad is a monorepo built with:
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:
| Package | Description | | 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)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech |
| **[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)** | SolidJS frontend — reactive, fast, beautiful |
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and 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 ### Quick Start
To build the Desktop App from source:
1. Clone the repo. ```bash
2. Run `npm install` (requires pnpm or npm 7+ for workspaces). git clone https://github.com/NeuralNomadsAI/CodeNomad.git
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`. 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", "name": "codenomad-workspace",
"version": "0.13.1", "version": "0.13.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -64,7 +64,6 @@
"version": "7.28.5", "version": "7.28.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -3381,7 +3380,6 @@
"version": "7.20.5", "version": "7.20.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.20.7", "@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7", "@babel/types": "^7.20.7",
@@ -3483,7 +3481,6 @@
"version": "22.19.0", "version": "22.19.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3558,7 +3555,6 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
"colorette": "^2.0.20", "colorette": "^2.0.20",
@@ -3641,7 +3637,6 @@
"version": "6.12.6", "version": "6.12.6",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -3844,6 +3839,7 @@
"version": "5.3.2", "version": "5.3.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^2.1.0", "archiver-utils": "^2.1.0",
"async": "^3.2.4", "async": "^3.2.4",
@@ -3861,6 +3857,7 @@
"version": "2.1.0", "version": "2.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.4", "glob": "^7.1.4",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -3881,6 +3878,7 @@
"version": "2.3.8", "version": "2.3.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -3894,12 +3892,14 @@
"node_modules/archiver-utils/node_modules/safe-buffer": { "node_modules/archiver-utils/node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/archiver-utils/node_modules/string_decoder": { "node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -4213,6 +4213,7 @@
"version": "4.1.0", "version": "4.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
"inherits": "^2.0.4", "inherits": "^2.0.4",
@@ -4276,7 +4277,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4767,6 +4767,7 @@
"version": "4.1.2", "version": "4.1.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer-crc32": "^0.2.13", "buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2", "crc32-stream": "^4.0.2",
@@ -4896,6 +4897,7 @@
"version": "1.2.2", "version": "1.2.2",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"crc32": "bin/crc32.njs" "crc32": "bin/crc32.njs"
}, },
@@ -4907,6 +4909,7 @@
"version": "4.0.3", "version": "4.0.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"crc-32": "^1.2.0", "crc-32": "^1.2.0",
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
@@ -5272,7 +5275,6 @@
"version": "24.13.3", "version": "24.13.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"builder-util": "24.13.1", "builder-util": "24.13.1",
@@ -5439,6 +5441,7 @@
"version": "24.13.3", "version": "24.13.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"archiver": "^5.3.1", "archiver": "^5.3.1",
@@ -5450,6 +5453,7 @@
"version": "10.1.0", "version": "10.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -5463,6 +5467,7 @@
"version": "6.2.0", "version": "6.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -5474,6 +5479,7 @@
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -6191,7 +6197,8 @@
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
@@ -7408,7 +7415,8 @@
"node_modules/isarray": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/isbinaryfile": { "node_modules/isbinaryfile": {
"version": "5.0.6", "version": "5.0.6",
@@ -7458,7 +7466,6 @@
"version": "1.21.7", "version": "1.21.7",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -7590,6 +7597,7 @@
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readable-stream": "^2.0.5" "readable-stream": "^2.0.5"
}, },
@@ -7601,6 +7609,7 @@
"version": "2.3.8", "version": "2.3.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -7614,12 +7623,14 @@
"node_modules/lazystream/node_modules/safe-buffer": { "node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lazystream/node_modules/string_decoder": { "node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -7684,22 +7695,26 @@
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.difference": { "node_modules/lodash.difference": {
"version": "4.5.0", "version": "4.5.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.sortby": { "node_modules/lodash.sortby": {
"version": "4.7.0", "version": "4.7.0",
@@ -7711,7 +7726,8 @@
"node_modules/lodash.union": { "node_modules/lodash.union": {
"version": "4.6.0", "version": "4.6.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lowercase-keys": { "node_modules/lowercase-keys": {
"version": "2.0.0", "version": "2.0.0",
@@ -8515,7 +8531,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -8663,7 +8678,8 @@
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/process-warning": { "node_modules/process-warning": {
"version": "3.0.0", "version": "3.0.0",
@@ -8912,6 +8928,7 @@
"version": "3.6.2", "version": "3.6.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
"string_decoder": "^1.1.1", "string_decoder": "^1.1.1",
@@ -8925,6 +8942,7 @@
"version": "1.1.3", "version": "1.1.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"minimatch": "^5.1.0" "minimatch": "^5.1.0"
} }
@@ -9227,7 +9245,6 @@
"version": "4.52.5", "version": "4.52.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -9451,7 +9468,6 @@
"node_modules/seroval": { "node_modules/seroval": {
"version": "1.3.2", "version": "1.3.2",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
@@ -9775,7 +9791,6 @@
"node_modules/solid-js": { "node_modules/solid-js": {
"version": "1.9.10", "version": "1.9.10",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.1.0", "csstype": "^3.1.0",
"seroval": "~1.3.0", "seroval": "~1.3.0",
@@ -9916,6 +9931,7 @@
"version": "1.3.0", "version": "1.3.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
@@ -10249,6 +10265,7 @@
"version": "2.2.0", "version": "2.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@@ -10441,7 +10458,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -10691,7 +10707,6 @@
"version": "5.9.3", "version": "5.9.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11039,7 +11054,6 @@
"version": "5.4.21", "version": "5.4.21",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -11524,7 +11538,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -11719,7 +11732,6 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
}, },
@@ -12008,6 +12020,7 @@
"version": "4.1.1", "version": "4.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^3.0.4", "archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2", "compress-commons": "^4.1.2",
@@ -12021,6 +12034,7 @@
"version": "3.0.4", "version": "3.0.4",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.2.3", "glob": "^7.2.3",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -12040,7 +12054,6 @@
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -12055,7 +12068,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12092,7 +12105,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12134,7 +12147,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12142,7 +12155,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.1", "version": "0.13.3",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",
@@ -22,7 +22,7 @@
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app", "build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --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", "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": { "dependencies": {
"7zip-bin": "^5.2.0", "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" "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
} }

View File

@@ -4,6 +4,23 @@ export interface Env {
export default { export default {
async fetch(request: Request, env: Env): Promise<Response> { 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) return env.ASSETS.fetch(request)
}, },
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.1", "version": "0.13.3",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

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

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.1", "version": "0.13.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.1", "version": "0.13.3",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,13 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad", "productName": "CodeNomad",
"version": "0.12.3", "version": "0.13.3",
"identifier": "ai.neuralnomads.codenomad.client", "identifier": "ai.neuralnomads.codenomad.client",
"build": { "build": {
"beforeDevCommand": "npm run dev:bootstrap", "beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server", "beforeBuildCommand": "npm run bundle:server",
"frontendDist": "resources/ui-loading" "frontendDist": "resources/ui-loading"
}, },
"app": { "app": {
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [ "windows": [
@@ -33,9 +30,13 @@
], ],
"security": { "security": {
"assetProtocol": { "assetProtocol": {
"scope": ["**"] "scope": [
"**"
]
}, },
"capabilities": ["main-window-native-dialogs"] "capabilities": [
"main-window-native-dialogs"
]
} }
}, },
"bundle": { "bundle": {
@@ -44,7 +45,17 @@
"resources/server", "resources/server",
"resources/ui-loading" "resources/ui-loading"
], ],
"icon": ["icon.icns", "icon.ico", "icon.png"], "icon": [
"targets": ["app", "appimage", "deb", "rpm", "nsis"] "icon.icns",
"icon.ico",
"icon.png"
],
"targets": [
"app",
"appimage",
"deb",
"rpm",
"nsis"
]
} }
} }

View File

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

View File

@@ -443,7 +443,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" 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")} 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) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
void openExternalUrl(GITHUB_URL, "folder-selection") void openExternalUrl(GITHUB_URL, "folder-selection")

View File

@@ -41,7 +41,7 @@ import SessionSidebar from "./shell/SessionSidebar"
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel" import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome" 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 { Maximize2, ShieldAlert } from "lucide-solid"
import type { LayoutMode } from "./shell/types" import type { LayoutMode } from "./shell/types"
@@ -104,6 +104,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null) const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [now, setNow] = createSignal(Date.now())
// Worktree selector manages its own dialogs. // Worktree selector manages its own dialogs.
const [showSessionSearch, setShowSessionSearch] = createSignal(false) const [showSessionSearch, setShowSessionSearch] = createSignal(false)
@@ -237,6 +238,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString()) 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 connectionStatus = () => sseManager.getStatus(props.instance.id)
const connectionStatusClass = () => { const connectionStatusClass = () => {
const status = connectionStatus() const status = connectionStatus()
@@ -306,17 +313,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
} }
const status = getSessionStatus(props.instance.id, activeSessionId) const status = getSessionStatus(props.instance.id, activeSessionId)
const text = const retry = getSessionRetry(props.instance.id, activeSessionId)
status === "working" 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") ? t("sessionList.status.working")
: status === "compacting" : status === "compacting"
? t("sessionList.status.compacting") ? t("sessionList.status.compacting")
: t("sessionList.status.idle") : t("sessionList.status.idle")
return { return {
className: `session-${status}`, className: `session-${retry ? "retrying" : status}`,
text, text,
showAlertIcon: false, showAlertIcon: false,
title: retry
? t("sessionList.status.retryTooltip", {
message: retry.message,
attempt: String(retry.attempt),
})
: undefined,
} }
}) })
@@ -324,7 +342,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const pill = activeSessionStatusPill() const pill = activeSessionStatusPill()
if (!pill) return null if (!pill) return null
return ( 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.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{pill.text} {pill.text}
</span> </span>

View File

@@ -123,7 +123,11 @@ export function Markdown(props: MarkdownProps) {
version: () => resolved().version, 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 = { const cacheEntry: RenderCache = {
text: snapshot.text, text: snapshot.text,
html: renderedHtml, html: renderedHtml,
@@ -131,7 +135,9 @@ export function Markdown(props: MarkdownProps) {
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`, mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
} }
setHtml(renderedHtml) setHtml(renderedHtml)
cacheHandle.set(cacheEntry) if (options?.cache ?? true) {
cacheHandle.set(cacheEntry)
}
notifyRendered() notifyRendered()
} }
@@ -142,9 +148,10 @@ export function Markdown(props: MarkdownProps) {
suppressHighlight: !snapshot.highlightEnabled, suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml, escapeRawHtml: snapshot.escapeRawHtml,
}) })
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
if (latestRequestKey === snapshot.requestKey) { 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 { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput" 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 log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker")) const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
@@ -492,6 +497,8 @@ export default function PromptInput(props: PromptInputProps) {
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => { const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
voiceButtonPressed = true voiceButtonPressed = true
// Treat a mic press as barge-in: stop any active assistant speech before listening.
clearConversationPlaybackForInstance(props.instanceId)
if (event instanceof PointerEvent) { if (event instanceof PointerEvent) {
const target = event.currentTarget const target = event.currentTarget

View File

@@ -1,7 +1,7 @@
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js" import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
import type { SessionStatus } from "../types/session" import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state" 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 { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import SessionRenameDialog from "./session-rename-dialog" import SessionRenameDialog from "./session-rename-dialog"
@@ -55,6 +55,13 @@ const SessionList: Component<SessionListProps> = (props) => {
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set()) const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
const [reloadingSessionIds, setReloadingSessionIds] = 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 normalizeSessionLabel = (sessionId: string) => {
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
@@ -400,7 +407,13 @@ const SessionList: Component<SessionListProps> = (props) => {
const isActive = () => props.activeSessionId === rowProps.sessionId const isActive = () => props.activeSessionId === rowProps.sessionId
const title = () => session()?.title || t("sessionList.session.untitled") const title = () => session()?.title || t("sessionList.session.untitled")
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId)
const statusLabel = () => { 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())) { switch (formatSessionStatus(status())) {
case "working": case "working":
return t("sessionList.status.working") return t("sessionList.status.working")
@@ -413,13 +426,21 @@ const SessionList: Component<SessionListProps> = (props) => {
const needsPermission = () => Boolean(session()?.pendingPermission) const needsPermission = () => Boolean(session()?.pendingPermission)
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion) const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
const needsInput = () => needsPermission() || needsQuestion() const needsInput = () => needsPermission() || needsQuestion()
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`) const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`)
const statusText = () => const statusText = () =>
needsPermission() needsPermission()
? t("sessionList.status.needsPermission") ? t("sessionList.status.needsPermission")
: needsQuestion() : needsQuestion()
? t("sessionList.status.needsInput") ? t("sessionList.status.needsInput")
: statusLabel() : 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) 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"}`} /> <ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span> </span>
</Show> </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" />} {needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()} {statusText()}
</span> </span>

View File

@@ -19,9 +19,6 @@ export function formatCompactCount(value: number): string {
return `${(value / 1_000_000).toFixed(1)}M` return `${(value / 1_000_000).toFixed(1)}M`
} }
if (value >= 10_000) { if (value >= 10_000) {
return `${Math.round(value / 1_000)}K`
}
if (value >= 1_000) {
const label = `${(value / 1_000).toFixed(1)}K` const label = `${(value / 1_000).toFixed(1)}K`
return label.replace(/\.0K$/, "K") return label.replace(/\.0K$/, "K")
} }

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Working", "sessionList.status.working": "Working",
"sessionList.status.compacting": "Compacting", "sessionList.status.compacting": "Compacting",
"sessionList.status.idle": "Idle", "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.needsPermission": "Needs Permission",
"sessionList.status.needsInput": "Needs Input", "sessionList.status.needsInput": "Needs Input",
"sessionList.expand.collapseAriaLabel": "Collapse session", "sessionList.expand.collapseAriaLabel": "Collapse session",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Trabajando", "sessionList.status.working": "Trabajando",
"sessionList.status.compacting": "Compactando", "sessionList.status.compacting": "Compactando",
"sessionList.status.idle": "Inactiva", "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.needsPermission": "Requiere permiso",
"sessionList.status.needsInput": "Requiere entrada", "sessionList.status.needsInput": "Requiere entrada",
"sessionList.expand.collapseAriaLabel": "Colapsar sesión", "sessionList.expand.collapseAriaLabel": "Colapsar sesión",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "En cours", "sessionList.status.working": "En cours",
"sessionList.status.compacting": "Compactage", "sessionList.status.compacting": "Compactage",
"sessionList.status.idle": "Inactif", "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.needsPermission": "Autorisation requise",
"sessionList.status.needsInput": "Entrée requise", "sessionList.status.needsInput": "Entrée requise",
"sessionList.expand.collapseAriaLabel": "Réduire la session", "sessionList.expand.collapseAriaLabel": "Réduire la session",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "עובד", "sessionList.status.working": "עובד",
"sessionList.status.compacting": "מסכם", "sessionList.status.compacting": "מסכם",
"sessionList.status.idle": "מוכן", "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.needsPermission": "נדרש אישור",
"sessionList.status.needsInput": "נדרש קלט", "sessionList.status.needsInput": "נדרש קלט",
"sessionList.expand.collapseAriaLabel": "כווץ סשן", "sessionList.expand.collapseAriaLabel": "כווץ סשן",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "作業中", "sessionList.status.working": "作業中",
"sessionList.status.compacting": "圧縮中", "sessionList.status.compacting": "圧縮中",
"sessionList.status.idle": "待機中", "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.needsPermission": "許可待ち",
"sessionList.status.needsInput": "入力待ち", "sessionList.status.needsInput": "入力待ち",
"sessionList.expand.collapseAriaLabel": "セッションを折りたたむ", "sessionList.expand.collapseAriaLabel": "セッションを折りたたむ",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Работает", "sessionList.status.working": "Работает",
"sessionList.status.compacting": "Компактация", "sessionList.status.compacting": "Компактация",
"sessionList.status.idle": "Простой", "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.needsPermission": "Требуется разрешение",
"sessionList.status.needsInput": "Требуется ввод", "sessionList.status.needsInput": "Требуется ввод",
"sessionList.expand.collapseAriaLabel": "Свернуть сессию", "sessionList.expand.collapseAriaLabel": "Свернуть сессию",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "工作中", "sessionList.status.working": "工作中",
"sessionList.status.compacting": "压缩中", "sessionList.status.compacting": "压缩中",
"sessionList.status.idle": "空闲", "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.needsPermission": "需要权限",
"sessionList.status.needsInput": "需要输入", "sessionList.status.needsInput": "需要输入",
"sessionList.expand.collapseAriaLabel": "折叠会话", "sessionList.expand.collapseAriaLabel": "折叠会话",

View File

@@ -120,14 +120,7 @@ function resolveLanguage(token: string): { canonical: string | null; raw: string
return { canonical: null, raw: normalized } return { canonical: null, raw: normalized }
} }
async function ensureLanguages(content: string) { function collectCodeFenceLanguages(content: string): 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 = new Set<string>() const foundLanguages = new Set<string>()
try { try {
const tokens = marked.lexer(content) as any const tokens = marked.lexer(content) as any
@@ -139,10 +132,44 @@ async function ensureLanguages(content: string) {
} }
}) })
} catch { } 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 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 // Queue language loading tasks
for (const token of foundLanguages) { for (const token of foundLanguages) {
const rawToken = normalizeLanguageToken(token) const rawToken = normalizeLanguageToken(token)

View File

@@ -102,9 +102,11 @@ export function showToastNotification(payload: ToastPayload): ToastHandle {
</button> </button>
<div class="flex items-start gap-3 pr-6"> <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}`} /> <span class={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${accent.badge}`} />
<div class="flex-1 text-sm leading-snug"> <div class="min-w-0 flex-1 text-sm leading-snug">
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>} {payload.title && <p class={`break-words ${accent.headline} font-semibold`}>{payload.title}</p>}
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p> <p class={`${accent.body} ${payload.title ? "mt-1" : ""} whitespace-pre-wrap break-words [overflow-wrap:anywhere]`}>
{payload.message}
</p>
{payload.action && ( {payload.action && (
<button <button
type="button" type="button"

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 { Message } from "../types/message"
import type { FileDiff } from "@opencode-ai/sdk/v2/client" import type { FileDiff } from "@opencode-ai/sdk/v2/client"
@@ -149,12 +149,15 @@ async function fetchSessions(instanceId: string): Promise<void> {
const existingStatus = existingSession?.status const existingStatus = existingSession?.status
let status: SessionStatus let status: SessionStatus
let retry = existingSession?.retry ?? null
if (existingStatus === "compacting") { if (existingStatus === "compacting") {
status = "compacting" status = "compacting"
retry = null
} else { } else {
const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id] const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle" status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle"
retry = hasType ? mapSdkSessionRetry(rawStatus) : retry
} }
sessionMap.set(apiSession.id, { sessionMap.set(apiSession.id, {
@@ -165,6 +168,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
agent: existingSession?.agent ?? "", agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" }, model: existingSession?.model ?? { providerId: "", modelId: "" },
status, status,
retry,
version: apiSession.version, version: apiSession.version,
time: { time: {
...apiSession.time, ...apiSession.time,

View File

@@ -28,7 +28,7 @@ import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "
import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question" import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question"
import type { QuestionRequest } from "../types/question" import type { QuestionRequest } from "../types/question"
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2" 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 { sendOsNotification } from "../lib/os-notifications"
import { preferences } from "./preferences" import { preferences } from "./preferences"
import { import {
@@ -39,7 +39,14 @@ import {
removeQuestionFromQueue, removeQuestionFromQueue,
} from "./instances" } from "./instances"
import { showAlertDialog } from "./alerts" 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 { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
@@ -67,6 +74,15 @@ import { handleConversationAssistantPartUpdated } from "./conversation-speech"
const log = getLogger("sse") const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>() 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 { function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
if (typeof document === "undefined") return false if (typeof document === "undefined") return false
@@ -131,18 +147,20 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"]) 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 let parentToExpand: string | null = null
withSession(instanceId, sessionId, (session) => { withSession(instanceId, sessionId, (session) => {
const current = session.status ?? "idle" 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") { if (current === "compacting" && status !== "compacting") {
return false return false
} }
session.status = status session.status = status
session.retry = status === "working" ? nextRetry : null
// Auto-expand the parent thread when a child session starts working. // Auto-expand the parent thread when a child session starts working.
// Users can still collapse it; we only expand on the transition. // 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 fetchedStatus: SessionStatus = "idle"
let fetchedRetry: SessionRetryState | null = null
try { try {
let statuses: Record<string, any> = {} let statuses: Record<string, any> = {}
try { try {
@@ -187,11 +206,13 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
const rawStatus = (info as any)?.status ?? statuses?.[sessionId] const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle" fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
fetchedRetry = hasType ? mapSdkSessionRetry(rawStatus) : null
} catch (error) { } catch (error) {
log.error("Failed to fetch session status", error) log.error("Failed to fetch session status", error)
} }
const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus) const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus)
fetched.retry = fetchedRetry
let updatedInstanceSessions: Map<string, Session> | undefined let updatedInstanceSessions: Map<string, Session> | undefined
let shouldExpandParent: string | null = null let shouldExpandParent: string | null = null
@@ -205,6 +226,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
agent: existing?.agent ?? fetched.agent, agent: existing?.agent ?? fetched.agent,
model: existing?.model ?? fetched.model, model: existing?.model ?? fetched.model,
status: existing?.status === "compacting" ? "compacting" : fetched.status, status: existing?.status === "compacting" ? "compacting" : fetched.status,
retry: existing?.status === "compacting" ? null : fetched.retry,
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission, pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
pendingQuestion: existing?.pendingQuestion ?? false, 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 instanceSessions = sessions().get(instanceId)
const existing = instanceSessions?.get(sessionId) const existing = instanceSessions?.get(sessionId)
if (existing) { if (existing) {
if ((existing.status ?? "idle") === status) { if ((existing.status ?? "idle") === status && isSameRetryState(existing.retry, retry)) {
return return
} }
applySessionStatus(instanceId, sessionId, status) applySessionStatus(instanceId, sessionId, status, retry)
return return
} }
@@ -250,7 +278,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
const pending = (async () => { const pending = (async () => {
const fetched = await fetchSessionInfo(instanceId, sessionId, directory) const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
if (!fetched) return if (!fetched) return
applySessionStatus(instanceId, sessionId, status) applySessionStatus(instanceId, sessionId, status, retry)
})() })()
pendingSessionFetches.set(key, pending) pendingSessionFetches.set(key, pending)
@@ -428,6 +456,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
modelId: "", modelId: "",
}, },
status: "idle", status: "idle",
retry: null,
version: info.version || "0", version: info.version || "0",
time: info.time time: info.time
? { ...info.time } ? { ...info.time }
@@ -461,6 +490,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
...existingSession, ...existingSession,
title: info.title || existingSession.title, title: info.title || existingSession.title,
status: existingSession.status ?? "idle", status: existingSession.status ?? "idle",
retry: existingSession.retry ?? null,
time: mergedTime, time: mergedTime,
revert: info.revert revert: info.revert
? { ? {
@@ -532,8 +562,29 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
const sessionId = event.properties?.sessionID const sessionId = event.properties?.sessionID
if (!sessionId) return if (!sessionId) return
const status = mapSdkSessionStatus(event.properties.status) const rawStatus = event.properties.status
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory) 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 }) log.info(`[SSE] Session status updated: ${sessionId}`, { status })
} }
@@ -547,6 +598,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
if (existing) { if (existing) {
withSession(instanceId, sessionID, (session) => { withSession(instanceId, sessionID, (session) => {
session.status = "working" session.status = "working"
session.retry = null
}) })
} else { } else {
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory) 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 if (session.status === status) return false
const previous = session.status const previous = session.status
session.status = status session.status = status
if (status !== "working") {
session.retry = null
}
// If a child session starts working, auto-expand its parent thread once. // If a child session starts working, auto-expand its parent thread once.
// Users can still collapse it afterwards; we only expand on the transition. // 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" import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
function getSession(instanceId: string, sessionId: string): Session | null { function getSession(instanceId: string, sessionId: string): Session | null {
@@ -14,6 +14,15 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
return session.status ?? "idle" 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 type InstanceSessionIndicatorStatus = "permission" | SessionStatus
export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus { export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus {

View File

@@ -184,6 +184,7 @@
} }
.status-indicator.session-status.session-working, .status-indicator.session-status.session-working,
.status-indicator.session-status.session-retrying,
.status-indicator.session-status.session-compacting, .status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle { .status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
@@ -194,6 +195,11 @@
--session-status-dot: var(--session-status-working-fg); --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 { .status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg); color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg); --session-status-dot: var(--session-status-compacting-fg);
@@ -222,6 +228,10 @@
background-color: var(--session-status-working-bg); 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 { .status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg); background-color: var(--session-status-compacting-bg);
} }

View File

@@ -416,6 +416,7 @@ session-sidebar-controls .selector-trigger-primary {
} }
.status-indicator.session-status.session-working, .status-indicator.session-status.session-working,
.status-indicator.session-status.session-retrying,
.status-indicator.session-status.session-compacting, .status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle { .status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
@@ -426,6 +427,11 @@ session-sidebar-controls .selector-trigger-primary {
--session-status-dot: var(--session-status-working-fg); --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 { .status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg); color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg); --session-status-dot: var(--session-status-compacting-fg);
@@ -454,6 +460,10 @@ session-sidebar-controls .selector-trigger-primary {
background-color: var(--session-status-working-bg); 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 { .status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg); background-color: var(--session-status-compacting-bg);
} }

View File

@@ -17,6 +17,12 @@ export type {
export type SessionStatus = "idle" | "working" | "compacting" export type SessionStatus = "idle" | "working" | "compacting"
export interface SessionRetryState {
attempt: number
message: string
next: number
}
export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus { export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus {
if (!status || status.type === "idle") { if (!status || status.type === "idle") {
return "idle" return "idle"
@@ -26,6 +32,18 @@ export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined)
return "working" 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 // Our client-specific Session interface extending SDK Session
export interface Session export interface Session
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> { 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 pendingPermission?: boolean // Indicates if session is waiting on user permission
pendingQuestion?: boolean // Indicates if session is waiting on user input pendingQuestion?: boolean // Indicates if session is waiting on user input
status: SessionStatus // Single source of truth for session status 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) 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")