Compare commits

..

8 Commits

Author SHA1 Message Date
Shantur Rathore
1f59e66065 ci: skip draft PR builds until ready 2026-03-22 19:30:13 +00:00
Pascal André
51ac7f152d perf(ui): defer Monaco secondary viewers 2026-03-21 22:56:02 +00:00
Pascal André
df74c06ba2 fix(ui): localize git changes overlay label 2026-03-21 22:56:02 +00:00
Pascal André
5f144ca24d fix(ui): retry deferred markdown renderer setup 2026-03-21 22:56:02 +00:00
Pascal André
de66b1349a fix(ui): tolerate markdown parts without ids 2026-03-21 22:56:02 +00:00
Pascal André
3d888fee64 fix(ui): tighten diff viewer review follow-ups 2026-03-21 22:56:02 +00:00
Pascal André
1abcc8ee3c perf(ui): slim git diff syntax highlighting 2026-03-21 22:56:02 +00:00
Pascal André
d0d5c309e6 perf(ui): lazy-load markdown and diff rendering 2026-03-21 22:56:02 +00:00
206 changed files with 1087 additions and 8841 deletions

View File

@@ -4,7 +4,6 @@ on:
pull_request_target: pull_request_target:
types: types:
- opened - opened
- edited
- synchronize - synchronize
- reopened - reopened
- ready_for_review - ready_for_review
@@ -20,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }} ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }} ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }} BASE_REF: ${{ github.event.pull_request.base.ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }} IS_DRAFT: ${{ github.event.pull_request.draft }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -38,7 +37,7 @@ jobs:
fi fi
normalized=",${ALLOWED_ACTORS}," normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT" echo "allowed=true" >> "$GITHUB_OUTPUT"
else else
echo "allowed=false" >> "$GITHUB_OUTPUT" echo "allowed=false" >> "$GITHUB_OUTPUT"

View File

@@ -4,7 +4,6 @@ on:
pull_request: pull_request:
types: types:
- opened - opened
- edited
- synchronize - synchronize
- reopened - reopened
- ready_for_review - ready_for_review
@@ -24,7 +23,7 @@ jobs:
allowed: ${{ steps.auth.outputs.allowed }} allowed: ${{ steps.auth.outputs.allowed }}
env: env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }} ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }} ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }} BASE_REF: ${{ github.event.pull_request.base.ref }}
steps: steps:
- name: Check PR authorization - name: Check PR authorization
@@ -38,11 +37,11 @@ jobs:
fi fi
normalized=",${ALLOWED_ACTORS}," normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT" echo "allowed=true" >> "$GITHUB_OUTPUT"
else else
echo "allowed=false" >> "$GITHUB_OUTPUT" echo "allowed=false" >> "$GITHUB_OUTPUT"
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2 echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
fi fi
build: build:

View File

@@ -4,7 +4,6 @@ on:
pull_request_target: pull_request_target:
types: types:
- opened - opened
- edited
- reopened - reopened
- synchronize - synchronize
@@ -18,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }} ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }} ACTOR: ${{ github.actor }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }} BASE_REF: ${{ github.event.pull_request.base.ref }}
steps: steps:
@@ -28,7 +27,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
normalized=",${ALLOWED_ACTORS}," normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "authorized=true" >> "$GITHUB_OUTPUT" echo "authorized=true" >> "$GITHUB_OUTPUT"
else else
echo "authorized=false" >> "$GITHUB_OUTPUT" echo "authorized=false" >> "$GITHUB_OUTPUT"
@@ -51,5 +50,5 @@ jobs:
- name: Fail unauthorized PR - name: Fail unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }} if: ${{ steps.auth.outputs.authorized != 'true' }}
run: | run: |
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2 echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
exit 1 exit 1

151
README.md
View File

@@ -1,127 +1,128 @@
# CodeNomad # CodeNomad
## The AI Coding Cockpit for OpenCode ## A fast, multi-instance workspace for running OpenCode sessions.
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. 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.
> 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>
## Features ![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
- **🚀 Multi-Instance Workspace** ![Image Previews](docs/screenshots/image-previews.png)
- **🌐 Remote Access** _Rich media previews for images and assets._
- **🧠 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
### 🖥️ Desktop App Choose the way that fits your workflow:
Available as both Electron and Tauri builds — choose based on your preference. ### 🖥️ Desktop App (Recommended)
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases). - **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.
| Platform | Formats | ### 🦀 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.
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
| Windows | NSIS Installer, ZIP (x64, ARM64) | - **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
| Linux | AppImage, deb, tar.gz (x64, ARM64) | - **Source**: Check out `packages/tauri-app` if you're interested in contributing.
### 💻 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
``` ```
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access. 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
```
### 🧪 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 in your `PATH` - **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
- **Node.js 18+** — for server mode or building from source - **Node.js 18+**: Required if running the CLI server or building from source.
---
## Development
CodeNomad is a monorepo built with:
| Package | Description |
|---------|-------------|
| **[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 Start
```bash
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
cd CodeNomad
npm install
npm run dev
```
---
## Troubleshooting ## Troubleshooting
<details> ### macOS says the app is damaged
<summary><strong>macOS: "CodeNomad.app is damaged and can't be opened"</strong></summary> 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`:
Gatekeeper flag due to missing notarization. Clear the quarantine attribute:
```bash ```bash
xattr -l /Applications/CodeNomad.app
xattr -dr com.apple.quarantine /Applications/CodeNomad.app xattr -dr com.apple.quarantine /Applications/CodeNomad.app
``` ```
On Intel Macs, also check **System Settings → Privacy & Security** on first launch. 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.
</details>
<details> ### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
<summary><strong>Linux (Wayland + NVIDIA): Tauri App closes immediately</strong></summary> 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.
WebKitGTK DMA-BUF/GBM issue. Run with: Try running with one of these environment variables:
```bash ```bash
# Most reliable workaround (can reduce rendering performance)
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
# Alternative for some Wayland setups
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
``` ```
See full workaround in the original README. If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
</details>
--- ```bash
#!/bin/bash
export WEBKIT_DISABLE_DMABUF_RENDERER=1
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
```
## Community Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
[![Star History](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date) ## 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 |
|---------|-------------|
| **[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. |
### Quick Build
To build the Desktop App from source:
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`.
[![Star History Chart](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.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 966 KiB

102
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.3", "version": "0.12.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.3", "version": "0.12.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -64,6 +64,7 @@
"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",
@@ -3380,6 +3381,7 @@
"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",
@@ -3481,6 +3483,7 @@
"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"
} }
@@ -3555,6 +3558,7 @@
"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",
@@ -3637,6 +3641,7 @@
"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",
@@ -3839,7 +3844,6 @@
"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",
@@ -3857,7 +3861,6 @@
"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",
@@ -3878,7 +3881,6 @@
"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",
@@ -3892,14 +3894,12 @@
"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,7 +4213,6 @@
"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",
@@ -4277,6 +4276,7 @@
} }
], ],
"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,7 +4767,6 @@
"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",
@@ -4897,7 +4896,6 @@
"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"
}, },
@@ -4909,7 +4907,6 @@
"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"
@@ -5275,6 +5272,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",
"builder-util": "24.13.1", "builder-util": "24.13.1",
@@ -5441,7 +5439,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",
"archiver": "^5.3.1", "archiver": "^5.3.1",
@@ -5453,7 +5450,6 @@
"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",
@@ -5467,7 +5463,6 @@
"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"
}, },
@@ -5479,7 +5474,6 @@
"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"
} }
@@ -6197,8 +6191,7 @@
"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",
@@ -7415,8 +7408,7 @@
"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",
@@ -7466,6 +7458,7 @@
"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"
} }
@@ -7597,7 +7590,6 @@
"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"
}, },
@@ -7609,7 +7601,6 @@
"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",
@@ -7623,14 +7614,12 @@
"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"
} }
@@ -7695,26 +7684,22 @@
"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",
@@ -7726,8 +7711,7 @@
"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",
@@ -8256,27 +8240,6 @@
"regex-recursion": "^6.0.2" "regex-recursion": "^6.0.2"
} }
}, },
"node_modules/openai": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/own-keys": { "node_modules/own-keys": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -8531,6 +8494,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -8678,8 +8642,7 @@
"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",
@@ -8928,7 +8891,6 @@
"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",
@@ -8942,7 +8904,6 @@
"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"
} }
@@ -9245,6 +9206,7 @@
"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"
}, },
@@ -9468,6 +9430,7 @@
"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"
} }
@@ -9791,6 +9754,7 @@
"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",
@@ -9931,7 +9895,6 @@
"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"
} }
@@ -10265,7 +10228,6 @@
"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",
@@ -10458,6 +10420,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -10707,6 +10670,7 @@
"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"
@@ -11054,6 +11018,7 @@
"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",
@@ -11538,6 +11503,7 @@
"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",
@@ -11732,6 +11698,7 @@
"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"
}, },
@@ -12020,7 +11987,6 @@
"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",
@@ -12034,7 +12000,6 @@
"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",
@@ -12068,7 +12033,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.3", "version": "0.12.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12105,7 +12070,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.3", "version": "0.12.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12115,7 +12080,6 @@
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2", "yaml": "^2.4.2",
@@ -12147,7 +12111,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.13.3", "version": "0.12.3",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12155,7 +12119,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.13.3", "version": "0.12.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.3", "version": "0.12.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": "node ./scripts/bump-version.js" "bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
}, },
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",

View File

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

View File

@@ -4,23 +4,6 @@ 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

@@ -2,4 +2,3 @@ node_modules/
dist/ dist/
release/ release/
.vite/ .vite/
electron/resources/server/

View File

@@ -1,6 +1,5 @@
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import fs from "fs" import fs from "fs"
import { requestMicrophoneAccess } from "./permissions"
import type { CliProcessManager, CliStatus } from "./process-manager" import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null let wakeLockId: number | null = null
@@ -112,11 +111,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { enabled: false } return { enabled: false }
}) })
ipcMain.handle(
"media:requestMicrophoneAccess",
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
)
ipcMain.handle( ipcMain.handle(
"notifications:show", "notifications:show",
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {

View File

@@ -6,7 +6,6 @@ import { dirname, join } from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu" import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc" import { setupCliIPC } from "./ipc"
import { configureMediaPermissionHandlers } from "./permissions"
import { CliProcessManager } from "./process-manager" import { CliProcessManager } from "./process-manager"
const mainFilename = fileURLToPath(import.meta.url) const mainFilename = fileURLToPath(import.meta.url)
@@ -328,6 +327,7 @@ function finalizeCliSwap(url: string) {
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
} }
const SESSION_COOKIE_NAME = "codenomad_session"
let bootstrapExchangeInFlight = false let bootstrapExchangeInFlight = false
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null { function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
@@ -350,7 +350,6 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
} }
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> { async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
const sessionCookieName = cliManager.getAuthCookieName()
const target = new URL("/api/auth/token", baseUrl) const target = new URL("/api/auth/token", baseUrl)
const body = JSON.stringify({ token }) const body = JSON.stringify({ token })
@@ -381,14 +380,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
return false return false
} }
const sessionId = extractCookieValue(result.setCookie, sessionCookieName) const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
if (!sessionId) { if (!sessionId) {
return false return false
} }
await session.defaultSession.cookies.set({ await session.defaultSession.cookies.set({
url: baseUrl, url: baseUrl,
name: sessionCookieName, name: SESSION_COOKIE_NAME,
value: sessionId, value: sessionId,
httpOnly: true, httpOnly: true,
path: "/", path: "/",
@@ -490,7 +489,6 @@ app.whenReady().then(() => {
if (isMac) { if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false) session.defaultSession.setSpellCheckerEnabled(false)
configureMediaPermissionHandlers(getAllowedRendererOrigins)
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {
window.webContents.session.setSpellCheckerEnabled(false) window.webContents.session.setSpellCheckerEnabled(false)
}) })

View File

@@ -1,58 +0,0 @@
import { session, systemPreferences } from "electron"
const isMac = process.platform === "darwin"
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
if (!origin) {
return false
}
try {
const normalized = new URL(origin).origin
return allowedOrigins.includes(normalized)
} catch {
return false
}
}
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
const isAudioMediaRequest = (permission: string, details?: unknown) => {
if (permission !== "media") {
return false
}
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
return mediaTypes.length === 0 || mediaTypes.includes("audio")
}
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
if (!isAudioMediaRequest(permission, details)) {
return false
}
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
})
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
if (!isAudioMediaRequest(permission, details)) {
callback(false)
return
}
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
})
}
export async function requestMicrophoneAccess(): Promise<boolean> {
if (!isMac) {
return true
}
const status = systemPreferences.getMediaAccessStatus("microphone")
if (status === "granted") {
return true
}
return systemPreferences.askForMediaAccess("microphone")
}

View File

@@ -1,20 +1,16 @@
import { spawn, spawnSync, type ChildProcess } from "child_process" import { spawn, spawnSync, type ChildProcess } from "child_process"
import { app, utilityProcess, type UtilityProcess } from "electron" import { app } from "electron"
import { createRequire } from "module" import { createRequire } from "module"
import { EventEmitter } from "events" import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs" import { existsSync, readFileSync } from "fs"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { fileURLToPath } from "url"
import { parse as parseYaml } from "yaml" import { parse as parseYaml } from "yaml"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url) const nodeRequire = createRequire(import.meta.url)
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = path.dirname(mainFilename)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
type CliState = "starting" | "ready" | "error" | "stopped" type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all" type ListeningMode = "local" | "all"
@@ -42,9 +38,6 @@ interface CliEntryResolution {
runnerPath?: string runnerPath?: string
} }
type ManagedChild = ChildProcess | UtilityProcess
type ChildLaunchMode = "spawn" | "utility"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function isYamlPath(filePath: string): boolean { function isYamlPath(filePath: string): boolean {
@@ -124,13 +117,11 @@ export declare interface CliProcessManager {
} }
export class CliProcessManager extends EventEmitter { export class CliProcessManager extends EventEmitter {
private child?: ManagedChild private child?: ChildProcess
private childLaunchMode: ChildLaunchMode = "spawn"
private status: CliStatus = { state: "stopped" } private status: CliStatus = { state: "stopped" }
private stdoutBuffer = "" private stdoutBuffer = ""
private stderrBuffer = "" private stderrBuffer = ""
private bootstrapToken: string | null = null private bootstrapToken: string | null = null
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
private requestedStop = false private requestedStop = false
async start(options: StartOptions): Promise<CliStatus> { async start(options: StartOptions): Promise<CliStatus> {
@@ -141,42 +132,14 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = "" this.stdoutBuffer = ""
this.stderrBuffer = "" this.stderrBuffer = ""
this.bootstrapToken = null this.bootstrapToken = null
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
this.requestedStop = false this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
const listeningMode = this.resolveListeningMode() const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode) const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host) const args = this.buildCliArgs(options, host)
let child: ManagedChild
if (this.shouldUsePackagedShellSupervisor(options)) {
const runtimePath = this.resolveShellNodeCommand()
const entryPath = this.resolveBundledProdEntry()
const supervisorPath = this.resolveCliSupervisorPath()
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
const supervisorPayload = JSON.stringify({
command: shellCommand.command,
args: shellCommand.args,
cwd: process.cwd(),
})
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
)
console.info(`[cli] utility supervisor: ${supervisorPath}`)
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
env: shellEnv,
stdio: "pipe",
serviceName: "CodeNomad CLI Supervisor",
})
this.childLaunchMode = "utility"
} else {
const cliEntry = this.resolveCliEntry(options)
console.info( console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
) )
@@ -189,7 +152,7 @@ export class CliProcessManager extends EventEmitter {
: this.buildDirectSpawn(cliEntry, args) : this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32" const detached = process.platform !== "win32"
child = spawn(spawnDetails.command, spawnDetails.args, { const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(), cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env, env,
@@ -198,10 +161,7 @@ export class CliProcessManager extends EventEmitter {
}) })
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
this.childLaunchMode = "spawn" if (!child.pid) {
}
if (this.childLaunchMode === "spawn" && !child.pid) {
console.error("[cli] spawn failed: no pid") console.error("[cli] spawn failed: no pid")
} }
@@ -216,37 +176,13 @@ export class CliProcessManager extends EventEmitter {
this.handleStream(data.toString(), "stderr") this.handleStream(data.toString(), "stderr")
}) })
if (this.childLaunchMode === "utility") { child.on("error", (error) => {
const utilityChild = child as UtilityProcess
utilityChild.on("error", (error) => {
const message = this.describeUtilityProcessError(error)
console.error("[cli] utility supervisor failed:", error)
this.updateStatus({ state: "error", error: message })
this.emit("error", new Error(message))
})
utilityChild.on("exit", (code) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
} else {
const spawnedChild = child as ChildProcess
spawnedChild.on("error", (error) => {
console.error("[cli] failed to start CLI:", error) console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message }) this.updateStatus({ state: "error", error: error.message })
this.emit("error", error) this.emit("error", error)
}) })
spawnedChild.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready" const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
@@ -257,7 +193,6 @@ export class CliProcessManager extends EventEmitter {
this.emit("exit", this.status) this.emit("exit", this.status)
this.child = undefined this.child = undefined
}) })
}
return new Promise<CliStatus>((resolve, reject) => { return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -284,22 +219,16 @@ export class CliProcessManager extends EventEmitter {
return return
} }
if (this.childLaunchMode === "utility") {
return this.stopUtilityChild(child as UtilityProcess)
}
const spawnedChild = child as ChildProcess
this.requestedStop = true this.requestedStop = true
const pid = spawnedChild.pid const pid = child.pid
if (!pid) { if (!pid) {
this.child = undefined this.child = undefined
this.updateStatus({ state: "stopped" }) this.updateStatus({ state: "stopped" })
return return
} }
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => { const tryKillPosixGroup = (signal: NodeJS.Signals) => {
try { try {
@@ -375,7 +304,7 @@ export class CliProcessManager extends EventEmitter {
sendStopSignal("SIGKILL") sendStopSignal("SIGKILL")
}, 30000) }, 30000)
spawnedChild.on("exit", () => { child.on("exit", () => {
clearTimeout(killTimeout) clearTimeout(killTimeout)
this.child = undefined this.child = undefined
console.info("[cli] CLI process exited") console.info("[cli] CLI process exited")
@@ -395,54 +324,10 @@ export class CliProcessManager extends EventEmitter {
}) })
} }
private stopUtilityChild(child: UtilityProcess): Promise<void> {
this.requestedStop = true
const pid = child.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return Promise.resolve()
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
try {
process.kill(pid, "SIGKILL")
} catch {
// no-op
}
}, 30000)
child.once("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
})
if (child.pid === undefined) {
clearTimeout(killTimeout)
this.child = undefined
this.updateStatus({ state: "stopped" })
resolve()
return
}
child.kill()
})
}
getStatus(): CliStatus { getStatus(): CliStatus {
return { ...this.status } return { ...this.status }
} }
getAuthCookieName(): string {
return this.authCookieName
}
private resolveListeningMode(): ListeningMode { private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig() return readListeningModeFromConfig()
} }
@@ -450,22 +335,14 @@ export class CliProcessManager extends EventEmitter {
private handleTimeout() { private handleTimeout() {
if (this.child) { if (this.child) {
const pid = this.child.pid const pid = this.child.pid
if (this.childLaunchMode === "utility") { if (pid && process.platform !== "win32") {
if (pid) {
try {
process.kill(pid, "SIGKILL")
} catch {
// no-op
}
}
} else if (pid && process.platform !== "win32") {
try { try {
process.kill(-pid, "SIGKILL") process.kill(-pid, "SIGKILL")
} catch { } catch {
;(this.child as ChildProcess).kill("SIGKILL") this.child.kill("SIGKILL")
} }
} else { } else {
;(this.child as ChildProcess).kill("SIGKILL") this.child.kill("SIGKILL")
} }
this.child = undefined this.child = undefined
} }
@@ -539,7 +416,7 @@ export class CliProcessManager extends EventEmitter {
} }
private buildCliArgs(options: StartOptions, host: string): string[] { private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName] const args = ["serve", "--host", host, "--generate-token"]
if (options.dev) { if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy. // Dev: run plain HTTP + Vite dev server proxy.
@@ -572,10 +449,6 @@ export class CliProcessManager extends EventEmitter {
return parts.join(" ") return parts.join(" ")
} }
private buildExecutableCommand(command: string, args: string[]): string {
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) { private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "tsx") { if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] } return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
@@ -646,58 +519,4 @@ export class CliProcessManager extends EventEmitter {
} }
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.") throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
} }
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
return !options.dev && app.isPackaged && process.platform === "darwin"
}
private resolveCliSupervisorPath(): string {
const candidates = [
path.join(process.resourcesPath, "cli-supervisor.cjs"),
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
}
private resolveShellNodeCommand(): string {
const configured = process.env.NODE_BINARY?.trim()
return configured && configured.length > 0 ? configured : "node"
}
private resolveBundledProdEntry(): string {
const candidates = [
path.join(process.resourcesPath, "server", "dist", "bin.js"),
path.join(mainDirname, "../resources/server/dist/bin.js"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
}
private describeUtilityProcessError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message
}
if (error && typeof error === "object") {
const typed = error as { type?: unknown; location?: unknown }
if (typeof typed.type === "string") {
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
}
}
return String(error)
}
} }

View File

@@ -20,7 +20,6 @@ const electronAPI = {
return null return null
} }
}, },
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
} }

View File

@@ -1,131 +0,0 @@
#!/usr/bin/env node
const { spawn } = require("child_process")
const SHUTDOWN_GRACE_MS = 30_000
let child = null
let shutdownTimer = null
function log(message, error) {
if (error) {
console.error(`[cli-supervisor] ${message}`, error)
return
}
console.log(`[cli-supervisor] ${message}`)
}
function clearShutdownTimer() {
if (shutdownTimer) {
clearTimeout(shutdownTimer)
shutdownTimer = null
}
}
function forwardStream(stream, target) {
if (!stream) return
stream.on("data", (chunk) => {
target.write(chunk)
})
}
function terminateChild(force) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return
}
try {
child.kill(force ? "SIGKILL" : "SIGTERM")
} catch {
// no-op
}
}
function requestShutdown(force = false) {
if (!child) {
process.exit(force ? 1 : 0)
return
}
terminateChild(force)
if (force) {
process.exit(1)
return
}
clearShutdownTimer()
shutdownTimer = setTimeout(() => {
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
terminateChild(true)
}, SHUTDOWN_GRACE_MS)
shutdownTimer.unref()
}
function installShutdownHandlers() {
process.on("SIGTERM", () => requestShutdown(false))
process.on("SIGINT", () => requestShutdown(false))
process.on("disconnect", () => requestShutdown(false))
process.on("uncaughtException", (error) => {
log("uncaught exception", error)
requestShutdown(true)
})
process.on("unhandledRejection", (error) => {
log("unhandled rejection", error)
requestShutdown(true)
})
}
function parsePayload() {
const raw = process.argv[2]
if (!raw) {
throw new Error("Supervisor payload is required")
}
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") {
throw new Error("Supervisor payload must be an object")
}
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
throw new Error("Supervisor payload command is required")
}
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
throw new Error("Supervisor payload args must be a string array")
}
return {
command: parsed.command,
args: parsed.args,
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
}
}
function main() {
installShutdownHandlers()
const payload = parsePayload()
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
child = spawn(payload.command, payload.args, {
cwd: payload.cwd,
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
})
forwardStream(child.stdout, process.stdout)
forwardStream(child.stderr, process.stderr)
child.on("error", (error) => {
log("failed to spawn shell command", error)
process.exit(1)
})
child.on("exit", (code, signal) => {
clearShutdownTimer()
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
process.exit()
})
}
main()

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.3", "version": "0.12.3",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -20,8 +20,6 @@
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev", "dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev", "dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts", "dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"prepare:resources": "node scripts/prepare-resources.js",
"prebuild": "npm run prepare:resources",
"build": "electron-vite build", "build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview", "preview": "electron-vite preview",
@@ -35,11 +33,8 @@
"build:linux-arm64": "node scripts/build.js linux-arm64", "build:linux-arm64": "node scripts/build.js linux-arm64",
"build:linux-rpm": "node scripts/build.js linux-rpm", "build:linux-rpm": "node scripts/build.js linux-rpm",
"build:all": "node scripts/build.js all", "build:all": "node scripts/build.js all",
"prepackage:mac": "npm run prepare:resources",
"package:mac": "electron-builder --mac", "package:mac": "electron-builder --mac",
"prepackage:win": "npm run prepare:resources",
"package:win": "electron-builder --win", "package:win": "electron-builder --win",
"prepackage:linux": "npm run prepare:resources",
"package:linux": "electron-builder --linux" "package:linux": "electron-builder --linux"
}, },
"dependencies": { "dependencies": {
@@ -87,12 +82,6 @@
} }
], ],
"mac": { "mac": {
"entitlements": "electron/resources/entitlements.mac.plist",
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
"extendInfo": {
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
},
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"target": [ "target": [
{ {

View File

@@ -111,12 +111,6 @@ async function build(platform) {
env: { NODE_PATH: workspaceNodeModulesPath }, env: { NODE_PATH: workspaceNodeModulesPath },
}) })
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 2/3: Building Electron app...\n") console.log("\n📦 Step 2/3: Building Electron app...\n")
await run(npmCmd, ["run", "build"]) await run(npmCmd, ["run", "build"])

View File

@@ -1,132 +0,0 @@
#!/usr/bin/env node
import fs from "fs"
import path, { join } from "path"
import { spawnSync } from "child_process"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const appDir = join(__dirname, "..")
const workspaceRoot = join(appDir, "..", "..")
const serverRoot = join(appDir, "..", "server")
const resourcesRoot = join(appDir, "electron", "resources")
const serverDest = join(resourcesRoot, "server")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
const serverSources = ["dist", "public", "node_modules", "package.json"]
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
function log(message) {
console.log(`[prepare-resources] ${message}`)
}
function ensureServerBuild() {
const distPath = join(serverRoot, "dist")
const publicPath = join(serverRoot, "public")
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
}
}
function ensureServerDependencies() {
if (fs.existsSync(serverDepsMarker)) {
return
}
log("installing production server dependencies")
const npmArgs = [
"install",
"--omit=dev",
"--ignore-scripts",
"--workspaces=false",
"--package-lock=false",
"--install-strategy=shallow",
"--fund=false",
"--audit=false",
]
const env = {
...process.env,
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
npm_config_workspaces: "false",
}
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`npm install exited with code ${result.status ?? 1}`)
}
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of serverSources) {
const from = join(serverRoot, name)
const to = join(serverDest, name)
if (!fs.existsSync(from)) {
throw new Error(`Missing required server artifact: ${from}`)
}
fs.cpSync(from, to, { recursive: true, dereference: true })
log(`copied ${name} to Electron resources`)
}
}
function stripNodeModuleBins() {
const root = join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
log(`removed ${removed} node_modules/.bin directories`)
}
}
async function main() {
ensureServerBuild()
ensureServerDependencies()
copyServerArtifacts()
stripNodeModuleBins()
}
main().catch((error) => {
console.error("[prepare-resources] failed:", error)
process.exit(1)
})

View File

@@ -14,5 +14,5 @@
"noEmit": true "noEmit": true
}, },
"include": ["electron/**/*.ts", "electron.vite.config.ts"], "include": ["electron/**/*.ts", "electron.vite.config.ts"],
"exclude": ["node_modules", "dist", "electron/resources/server"] "exclude": ["node_modules", "dist"]
} }

View File

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

View File

@@ -2,8 +2,6 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process" import { createBackgroundProcessTools } from "./lib/background-process"
let voiceModeEnabled = false
export async function CodeNomadPlugin(input: PluginInput) { export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig() const config = getCodeNomadConfig()
const client = createCodeNomadClient(config) const client = createCodeNomadClient(config)
@@ -18,11 +16,6 @@ export async function CodeNomadPlugin(input: PluginInput) {
pingTs: (event.properties as any)?.ts, pingTs: (event.properties as any)?.ts,
}, },
}).catch(() => {}) }).catch(() => {})
return
}
if (event.type === "codenomad.voiceMode") {
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
} }
}) })
@@ -30,13 +23,6 @@ export async function CodeNomadPlugin(input: PluginInput) {
tool: { tool: {
...backgroundProcessTools, ...backgroundProcessTools,
}, },
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
if (!voiceModeEnabled) {
return
}
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
},
async event(input: { event: any }) { async event(input: { event: any }) {
const opencodeEvent = input?.event const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return if (!opencodeEvent || typeof opencodeEvent !== "object") return
@@ -44,19 +30,3 @@ export async function CodeNomadPlugin(input: PluginInput) {
}, },
} }
} }
function buildVoiceModePrompt(): string {
return [
"Voice conversation mode is enabled.",
"Prepend your reply with a fenced code block using language `spoken`.",
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
"Do not add generic phrases about whether the user should read more.",
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
"After the `spoken` block, continue with your normal detailed response.",
"Example:",
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
].join("\n\n")
}

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.3", "version": "0.12.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.3", "version": "0.12.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.3", "version": "0.12.3",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -32,7 +32,6 @@
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2", "yaml": "^2.4.2",

View File

@@ -207,43 +207,6 @@ export interface BinaryValidationResult {
error?: string error?: string
} }
export interface SpeechSegment {
startMs: number
endMs: number
text: string
}
export interface SpeechCapabilitiesResponse {
available: boolean
configured: boolean
provider: string
supportsStt: boolean
supportsTts: boolean
supportsStreamingTts: boolean
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
ttsFormats: string[]
streamingTtsFormats: string[]
}
export interface SpeechTranscriptionResponse {
text: string
language?: string
durationMs?: number
segments?: SpeechSegment[]
}
export interface SpeechSynthesisResponse {
audioBase64: string
mimeType: string
}
export interface VoiceModeStateResponse {
enabled: boolean
}
export type WorkspaceEventType = export type WorkspaceEventType =
| "workspace.created" | "workspace.created"
| "workspace.started" | "workspace.started"

View File

@@ -16,18 +16,16 @@ export interface AuthManagerInit {
password?: string password?: string
generateToken: boolean generateToken: boolean
dangerouslySkipAuth?: boolean dangerouslySkipAuth?: boolean
cookieName?: string
} }
export class AuthManager { export class AuthManager {
private readonly authStore: AuthStore | null private readonly authStore: AuthStore | null
private readonly tokenManager: TokenManager | null private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager() private readonly sessionManager = new SessionManager()
private readonly cookieName: string private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
private readonly authEnabled: boolean private readonly authEnabled: boolean
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
this.cookieName = sanitizeCookieName(init.cookieName)
this.authEnabled = !Boolean(init.dangerouslySkipAuth) this.authEnabled = !Boolean(init.dangerouslySkipAuth)
if (!this.authEnabled) { if (!this.authEnabled) {
@@ -141,16 +139,6 @@ export class AuthManager {
} }
} }
function sanitizeCookieName(value: string | undefined): string {
const trimmed = value?.trim()
if (!trimmed) {
return DEFAULT_AUTH_COOKIE_NAME
}
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
}
function resolveAuthFilePath(configPath: string) { function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath) const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json") return path.join(path.dirname(resolvedConfigPath), "auth.json")

View File

@@ -1,128 +0,0 @@
import type { Logger } from "../logger"
const STALE_CONNECTION_TIMEOUT_MS = 45000
const STALE_SWEEP_INTERVAL_MS = 5000
export interface ClientConnectionRef {
clientId: string
connectionId: string
}
export interface ClientConnectionRecord extends ClientConnectionRef {
key: string
connectedAt: number
lastSeenAt: number
}
type ConnectionChangeEvent = {
type: "connected" | "disconnected"
connection: ClientConnectionRecord
reason?: string
}
interface RegisteredConnection extends ClientConnectionRecord {
close: () => void
}
export class ClientConnectionManager {
private readonly connections = new Map<string, RegisteredConnection>()
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
private readonly sweepTimer: NodeJS.Timeout
constructor(private readonly logger: Logger) {
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
this.sweepTimer.unref?.()
}
shutdown(): void {
clearInterval(this.sweepTimer)
for (const connection of Array.from(this.connections.values())) {
this.disconnect(connection.key, "shutdown", false)
}
}
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
this.subscribers.add(listener)
return () => this.subscribers.delete(listener)
}
register(input: ClientConnectionRef & { close: () => void }): () => void {
const key = getConnectionKey(input)
const now = Date.now()
const existing = this.connections.get(key)
if (existing) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
this.disconnect(key, "replaced")
}
const connection: RegisteredConnection = {
key,
clientId: input.clientId,
connectionId: input.connectionId,
connectedAt: now,
lastSeenAt: now,
close: input.close,
}
this.connections.set(key, connection)
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
this.notify({ type: "connected", connection })
return () => this.disconnect(key, "closed")
}
pong(input: ClientConnectionRef): boolean {
const key = getConnectionKey(input)
const connection = this.connections.get(key)
if (!connection) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
return false
}
connection.lastSeenAt = Date.now()
return true
}
isConnected(input: ClientConnectionRef): boolean {
return this.connections.has(getConnectionKey(input))
}
private sweepStaleConnections(): void {
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
for (const connection of Array.from(this.connections.values())) {
if (connection.lastSeenAt > cutoff) continue
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
this.disconnect(connection.key, "timeout")
}
}
private disconnect(key: string, reason: string, invokeClose = true): void {
const connection = this.connections.get(key)
if (!connection) return
this.connections.delete(key)
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
if (invokeClose) {
try {
connection.close()
} catch (error) {
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
}
}
this.notify({ type: "disconnected", connection, reason })
}
private notify(event: ConnectionChangeEvent): void {
for (const subscriber of this.subscribers) {
try {
subscriber(event)
} catch (error) {
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
}
}
}
}
function getConnectionKey(input: ClientConnectionRef): string {
return `${input.clientId}:${input.connectionId}`
}

View File

@@ -81,14 +81,6 @@ export class FileSystemBrowser {
return { path: relativePath, absolutePath } return { path: relativePath, absolutePath }
} }
writeFile(relativePath: string, contents: string): void {
if (this.unrestricted) {
throw new Error("writeFile is not available in unrestricted mode")
}
const resolved = this.toRestrictedAbsolute(relativePath)
fs.writeFileSync(resolved, contents, "utf-8")
}
readFile(relativePath: string): string { readFile(relativePath: string): string {
if (this.unrestricted) { if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode") throw new Error("readFile is not available in unrestricted mode")

View File

@@ -19,11 +19,10 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher" import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui" import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls" import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -55,7 +54,6 @@ interface CliOptions {
launch: boolean launch: boolean
authUsername: string authUsername: string
authPassword?: string authPassword?: string
authCookieName: string
generateToken: boolean generateToken: boolean
dangerouslySkipAuth: boolean dangerouslySkipAuth: boolean
} }
@@ -101,11 +99,6 @@ function parseCliOptions(argv: string[]): CliOptions {
.default(DEFAULT_AUTH_USERNAME), .default(DEFAULT_AUTH_USERNAME),
) )
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD")) .addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
.env("CODENOMAD_AUTH_COOKIE_NAME")
.default(DEFAULT_AUTH_COOKIE_NAME),
)
.addOption( .addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop") new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN") .env("CODENOMAD_GENERATE_TOKEN")
@@ -145,7 +138,6 @@ function parseCliOptions(argv: string[]): CliOptions {
launch?: boolean launch?: boolean
username: string username: string
password?: string password?: string
authCookieName: string
generateToken?: boolean generateToken?: boolean
dangerouslySkipAuth?: boolean dangerouslySkipAuth?: boolean
}>() }>()
@@ -192,7 +184,6 @@ function parseCliOptions(argv: string[]): CliOptions {
launch: Boolean(parsed.launch), launch: Boolean(parsed.launch),
authUsername: parsed.username, authUsername: parsed.username,
authPassword: parsed.password, authPassword: parsed.password,
authCookieName: parsed.authCookieName,
generateToken: Boolean(parsed.generateToken), generateToken: Boolean(parsed.generateToken),
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth), dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
} }
@@ -274,7 +265,6 @@ async function main() {
configPath: configLocation.configYamlPath, configPath: configLocation.configYamlPath,
username: options.authUsername, username: options.authUsername,
password: options.authPassword, password: options.authPassword,
cookieName: options.authCookieName,
generateToken: options.generateToken, generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth, dangerouslySkipAuth: options.dangerouslySkipAuth,
}, },
@@ -314,7 +304,6 @@ async function main() {
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir) const instanceStore = new InstanceStore(configLocation.instancesDir)
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
const instanceEventBridge = new InstanceEventBridge({ const instanceEventBridge = new InstanceEventBridge({
workspaceManager, workspaceManager,
eventBus, eventBus,
@@ -399,7 +388,6 @@ async function main() {
eventBus, eventBus,
serverMeta, serverMeta,
instanceStore, instanceStore,
speechService,
authManager, authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl, uiDevServerUrl: uiResolution.uiDevServerUrl,
@@ -420,7 +408,6 @@ async function main() {
eventBus, eventBus,
serverMeta, serverMeta,
instanceStore, instanceStore,
speechService,
authManager, authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined, uiDevServerUrl: undefined,
@@ -451,23 +438,19 @@ async function main() {
// which can lead clients to talk to the wrong process. // which can lead clients to talk to the wrong process.
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}` const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
let remoteUrl: string | undefined let remoteUrl: string | undefined
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
if (remoteStart) { if (remoteStart) {
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host) const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
let remoteHost = options.host let remoteHost = options.host
if (wantsAll) { if (wantsAll) {
if (options.host === "0.0.0.0") { if (options.host === "0.0.0.0") {
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteAddresses = resolved.userVisible remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
} }
} else { } else {
remoteHost = "localhost" remoteHost = "localhost"
} }
if (!remoteUrl) {
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}` remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
} }
}
serverMeta.localUrl = localUrl serverMeta.localUrl = localUrl
serverMeta.localPort = localStart.port serverMeta.localPort = localStart.port
@@ -477,9 +460,7 @@ async function main() {
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local" serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
if (serverMeta.remotePort && remoteUrl) { if (serverMeta.remotePort && remoteUrl) {
serverMeta.addresses = remoteAddresses.length serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
? remoteAddresses
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
} else { } else {
serverMeta.addresses = [] serverMeta.addresses = []
} }
@@ -487,16 +468,6 @@ async function main() {
console.log(`Local Connection URL : ${serverMeta.localUrl}`) console.log(`Local Connection URL : ${serverMeta.localUrl}`)
if (serverMeta.remoteUrl) { if (serverMeta.remoteUrl) {
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`) console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
const additionalRemoteUrls = serverMeta.addresses
.map((addr) => addr.remoteUrl)
.filter((url) => url !== serverMeta.remoteUrl)
if (additionalRemoteUrls.length > 0) {
console.log("Other Accessible URLs:")
for (const url of additionalRemoteUrls) {
console.log(` - ${url}`)
}
}
} }
if (options.launch) { if (options.launch) {

View File

@@ -1,96 +0,0 @@
import type { Logger } from "../logger"
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
import type { PluginChannelManager } from "./channel"
interface VoiceModeManagerOptions {
connections: ClientConnectionManager
channel: PluginChannelManager
logger: Logger
}
export class VoiceModeManager {
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
private readonly aggregateByInstance = new Map<string, boolean>()
constructor(private readonly options: VoiceModeManagerOptions) {
this.options.connections.subscribe((event) => {
if (event.type !== "disconnected") return
this.clearConnection(event.connection)
})
}
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
if (enabled && !this.options.connections.isConnected(connection)) {
this.options.logger.debug(
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
"Ignoring voice mode enable for disconnected client connection",
)
return
}
const key = getConnectionKey(connection)
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
if (enabled) {
current.add(key)
this.enabledConnectionsByInstance.set(instanceId, current)
} else if (current.delete(key)) {
if (current.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
} else {
this.enabledConnectionsByInstance.set(instanceId, current)
}
}
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
this.publishIfChanged(instanceId)
}
syncInstance(instanceId: string): void {
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
}
isEnabled(instanceId: string): boolean {
return this.aggregateByInstance.get(instanceId) === true
}
private clearConnection(connection: ClientConnectionRef): void {
const key = getConnectionKey(connection)
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
if (!enabledConnections.delete(key)) continue
if (enabledConnections.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
}
this.publishIfChanged(instanceId)
}
}
private publishIfChanged(instanceId: string): void {
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
const previous = this.aggregateByInstance.get(instanceId) === true
if (enabled === previous) return
if (enabled) {
this.aggregateByInstance.set(instanceId, true)
} else {
this.aggregateByInstance.delete(instanceId)
}
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
}
}
function buildVoiceModeEvent(enabled: boolean) {
return {
type: "codenomad.voiceMode",
properties: {
enabled,
formatVersion: "v1",
},
}
}
function getConnectionKey(connection: ClientConnectionRef): string {
return `${connection.clientId}:${connection.connectionId}`
}

View File

@@ -1,94 +0,0 @@
import assert from "node:assert/strict"
import os from "node:os"
import { describe, it } from "node:test"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
describe("resolveNetworkAddresses", () => {
it("preserves interface order among external addresses", () => {
const addresses = [
{ address: "172.24.0.1", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "10.0.0.8", family: 4, internal: false },
{ address: "127.0.0.1", family: "IPv4", internal: true },
{ address: "169.254.10.20", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.map((entry) => entry.ip),
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
)
})
})
})
describe("resolveRemoteAddresses", () => {
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
const addresses = [
{ address: "169.254.10.20", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "172.24.0.1", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.userVisible.map((entry) => entry.ip),
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
)
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
})
})
it("prefers private LAN addresses over public addresses", () => {
const addresses = [
{ address: "203.0.113.40", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "8.8.8.8", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.userVisible.map((entry) => entry.ip),
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
)
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
})
})
it("uses a public address when no private LAN address is available", () => {
const addresses = [
{ address: "169.254.10.20", family: "IPv4", internal: false },
{ address: "203.0.113.40", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
})
})
})
function usingMockedNetworkInterfaces(
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
callback: () => void,
) {
const original = os.networkInterfaces
os.networkInterfaces = (() => ({
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
})) as typeof os.networkInterfaces
try {
callback()
} finally {
os.networkInterfaces = original
}
}

View File

@@ -21,17 +21,12 @@ import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin" import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees" import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { ServerMeta } from "../api-types" import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store" import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager" import { BackgroundProcessManager } from "../background-processes/manager"
import type { AuthManager } from "../auth/manager" import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth" import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
import type { SpeechService } from "../speech/service"
import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
interface HttpServerDeps { interface HttpServerDeps {
bindHost: string bindHost: string
@@ -46,7 +41,6 @@ interface HttpServerDeps {
eventBus: EventBus eventBus: EventBus
serverMeta: ServerMeta serverMeta: ServerMeta
instanceStore: InstanceStore instanceStore: InstanceStore
speechService: SpeechService
authManager: AuthManager authManager: AuthManager
uiStaticDir: string uiStaticDir: string
uiDevServerUrl?: string uiDevServerUrl?: string
@@ -176,13 +170,6 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }), logger: deps.logger.child({ component: "background-processes" }),
}) })
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
logger: deps.logger.child({ component: "voice-mode" }),
})
registerAuthRoutes(app, { authManager: deps.authManager }) registerAuthRoutes(app, { authManager: deps.authManager })
@@ -258,26 +245,14 @@ export function createHttpServer(deps: HttpServerDeps) {
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
eventBus: deps.eventBus,
registerClient: registerSseClient,
logger: sseLogger,
connectionManager: clientConnectionManager,
})
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, { registerStorageRoutes(app, {
instanceStore: deps.instanceStore, instanceStore: deps.instanceStore,
eventBus: deps.eventBus, eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
}) })
registerSpeechRoutes(app, { speechService: deps.speechService }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerPluginRoutes(app, {
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: proxyLogger,
channel: pluginChannel,
voiceModeManager,
})
registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
@@ -342,7 +317,6 @@ export function createHttpServer(deps: HttpServerDeps) {
}, },
stop: () => { stop: () => {
closeSseClients() closeSseClients()
clientConnectionManager.shutdown()
return app.close() return app.close()
}, },
} }

View File

@@ -1,12 +1,6 @@
import os from "os" import os from "os"
import type { NetworkAddress } from "../api-types" import type { NetworkAddress } from "../api-types"
export interface ResolvedRemoteAddresses {
all: NetworkAddress[]
userVisible: NetworkAddress[]
primaryRemoteUrl?: string
}
export function resolveNetworkAddresses(args: { export function resolveNetworkAddresses(args: {
host: string host: string
protocol: "http" | "https" protocol: "http" | "https"
@@ -64,57 +58,10 @@ export function resolveNetworkAddresses(args: {
return results.sort((a, b) => { return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
return 0
}) })
} }
export function resolveRemoteAddresses(args: {
host: string
protocol: "http" | "https"
port: number
}): ResolvedRemoteAddresses {
const all = resolveNetworkAddresses(args)
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
return {
all,
userVisible,
primaryRemoteUrl: userVisible[0]?.remoteUrl,
}
}
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
}
function getUserVisiblePriority(ip: string): number {
if (isPrivateIPv4(ip)) return 0
if (isLinkLocalIPv4(ip)) return 2
return 1
}
function isLinkLocalIPv4(ip: string): boolean {
const octets = parseIPv4(ip)
if (!octets) return false
const [first, second] = octets
return first === 169 && second === 254
}
function isPrivateIPv4(ip: string): boolean {
const octets = parseIPv4(ip)
if (!octets) return false
const [first, second] = octets
if (first === 10) return true
if (first === 192 && second === 168) return true
return first === 172 && second >= 16 && second <= 31
}
function parseIPv4(value: string): number[] | null {
if (!isIPv4Address(value)) return null
return value.split(".").map((part) => Number(part))
}
function isIPv4Address(value: string | undefined): value is string { function isIPv4Address(value: string | undefined): value is string {
if (!value) return false if (!value) return false
const parts = value.split(".") const parts = value.split(".")

View File

@@ -1,32 +1,19 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod"
import { EventBus } from "../../events/bus" import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types" import { WorkspaceEventPayload } from "../../api-types"
import type { ClientConnectionManager } from "../../clients/connection-manager"
import { Logger } from "../../logger" import { Logger } from "../../logger"
interface RouteDeps { interface RouteDeps {
eventBus: EventBus eventBus: EventBus
registerClient: (cleanup: () => void) => () => void registerClient: (cleanup: () => void) => () => void
logger: Logger logger: Logger
connectionManager: ClientConnectionManager
} }
let nextClientId = 0 let nextClientId = 0
const ConnectionQuerySchema = z.object({
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
})
const PongBodySchema = ConnectionQuerySchema.extend({
pingTs: z.number().optional(),
})
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => { app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId const clientId = ++nextClientId
const connection = ConnectionQuerySchema.parse(request.query ?? {})
deps.logger.debug({ clientId }, "SSE client connected") deps.logger.debug({ clientId }, "SSE client connected")
const origin = request.headers.origin ?? "*" const origin = request.headers.origin ?? "*"
@@ -48,8 +35,7 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
const unsubscribe = deps.eventBus.onEvent(send) const unsubscribe = deps.eventBus.onEvent(send)
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
const ping = { ts: Date.now() } reply.raw.write(`:hb ${Date.now()}\n\n`)
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
}, 15000) }, 15000)
let closed = false let closed = false
@@ -63,27 +49,13 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
} }
const unregister = deps.registerClient(close) const unregister = deps.registerClient(close)
const unregisterConnection = deps.connectionManager.register({
...connection,
close,
})
const handleClose = () => { const handleClose = () => {
close() close()
unregister() unregister()
unregisterConnection()
} }
request.raw.on("close", handleClose) request.raw.on("close", handleClose)
request.raw.on("error", handleClose) request.raw.on("error", handleClose)
}) })
app.post("/api/client-connections/pong", (request, reply) => {
const body = PongBodySchema.parse(request.body ?? {})
if (!deps.connectionManager.pong(body)) {
reply.code(404).send({ error: "Client connection not found" })
return
}
reply.code(204).send()
})
} }

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { ServerMeta } from "../../api-types" import { ServerMeta } from "../../api-types"
import { resolveNetworkAddresses } from "../network-addresses"
interface RouteDeps { interface RouteDeps {
serverMeta: ServerMeta serverMeta: ServerMeta
@@ -13,12 +13,14 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
function buildMetaResponse(meta: ServerMeta): ServerMeta { function buildMetaResponse(meta: ServerMeta): ServerMeta {
const localPort = resolveLocalPort(meta) const localPort = resolveLocalPort(meta)
const remote = resolveRemote(meta) const remote = resolveRemote(meta)
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
return { return {
...meta, ...meta,
localPort, localPort,
remotePort: remote?.port, remotePort: remote?.port,
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local", listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
addresses,
} }
} }

View File

@@ -1,19 +1,15 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import type { VoiceModeStateResponse } from "../../api-types"
import type { WorkspaceManager } from "../../workspaces/manager" import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus" import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger" import type { Logger } from "../../logger"
import { PluginChannelManager } from "../../plugins/channel" import { PluginChannelManager } from "../../plugins/channel"
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers" import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
import { VoiceModeManager } from "../../plugins/voice-mode"
interface RouteDeps { interface RouteDeps {
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
eventBus: EventBus eventBus: EventBus
logger: Logger logger: Logger
channel: PluginChannelManager
voiceModeManager: VoiceModeManager
} }
const PluginEventSchema = z.object({ const PluginEventSchema = z.object({
@@ -21,13 +17,9 @@ const PluginEventSchema = z.object({
properties: z.record(z.unknown()).optional(), properties: z.record(z.unknown()).optional(),
}) })
const VoiceModeStateSchema = z.object({
enabled: z.boolean(),
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => { app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id) const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) { if (!workspace) {
@@ -41,11 +33,10 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.raw.flushHeaders?.() reply.raw.flushHeaders?.()
reply.hijack() reply.hijack()
const registration = deps.channel.register(request.params.id, reply) const registration = channel.register(request.params.id, reply)
deps.voiceModeManager.syncInstance(request.params.id)
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
deps.channel.send(request.params.id, buildPingEvent()) channel.send(request.params.id, buildPingEvent())
}, 15000) }, 15000)
const close = () => { const close = () => {
@@ -58,22 +49,6 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
request.raw.on("error", close) request.raw.on("error", close)
}) })
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const payload = VoiceModeStateSchema.parse(request.body ?? {})
deps.voiceModeManager.setEnabled(
request.params.id,
{ clientId: payload.clientId, connectionId: payload.connectionId },
payload.enabled,
)
return { enabled: payload.enabled }
})
const handleWildcard = async (request: any, reply: any) => { const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId) const workspace = deps.workspaceManager.get(workspaceId)

View File

@@ -3,7 +3,6 @@ import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/runtime" import { probeBinaryVersion } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service" import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger" import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
interface RouteDeps { interface RouteDeps {
settings: SettingsService settings: SettingsService
@@ -21,10 +20,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access // Full-document access
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config"))) app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
app.patch("/api/storage/config", async (request, reply) => { app.patch("/api/storage/config", async (request, reply) => {
try { try {
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {})) return deps.settings.mergePatchDoc("config", request.body ?? {})
} catch (error) { } catch (error) {
reply.code(400) reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" } return { error: error instanceof Error ? error.message : "Invalid patch" }
@@ -32,15 +31,12 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
}) })
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => { app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner)) return deps.settings.getOwner("config", request.params.owner)
}) })
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => { app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
try { try {
return sanitizeConfigOwner( return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
request.params.owner,
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
)
} catch (error) { } catch (error) {
reply.code(400) reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" } return { error: error instanceof Error ? error.message : "Invalid patch" }

View File

@@ -1,74 +0,0 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SpeechService } from "../../speech/service"
interface RouteDeps {
speechService: SpeechService
}
const TranscribeBodySchema = z.object({
audioBase64: z.string().min(1, "Audio payload is required"),
mimeType: z.string().min(1, "Audio MIME type is required"),
filename: z.string().optional(),
language: z.string().optional(),
prompt: z.string().optional(),
})
const SynthesizeBodySchema = z.object({
text: z.string().trim().min(1, "Text is required"),
format: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
})
function getSpeechErrorStatus(error: unknown): number {
if (error instanceof z.ZodError) {
return 400
}
if (error instanceof Error && /not configured/i.test(error.message)) {
return 503
}
return 502
}
function getSpeechErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback
}
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
app.post("/api/speech/transcribe", async (request, reply) => {
try {
const body = TranscribeBodySchema.parse(request.body ?? {})
return await deps.speechService.transcribe(body)
} catch (error) {
request.log.error({ err: error }, "Failed to transcribe audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") }
}
})
app.post("/api/speech/synthesize", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
return await deps.speechService.synthesize(body)
} catch (error) {
request.log.error({ err: error }, "Failed to synthesize audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
}
})
app.post("/api/speech/synthesize/stream", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
const result = await deps.speechService.synthesizeStream(body)
reply.header("Content-Type", result.mimeType)
reply.header("Cache-Control", "no-store")
return reply.send(result.stream)
} catch (error) {
request.log.error({ err: error }, "Failed to stream synthesized audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to stream synthesized audio") }
}
})
}

View File

@@ -19,10 +19,6 @@ const WorkspaceFileContentQuerySchema = z.object({
path: z.string(), path: z.string(),
}) })
const WorkspaceFileContentBodySchema = z.object({
contents: z.string(),
})
const WorkspaceFileSearchQuerySchema = z.object({ const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"), q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(), limit: z.coerce.number().int().positive().max(200).optional(),
@@ -104,20 +100,6 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return handleWorkspaceError(error, reply) return handleWorkspaceError(error, reply)
} }
}) })
app.put<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files/content", async (request, reply) => {
try {
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
reply.code(204)
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
} }

View File

@@ -1,40 +0,0 @@
import type { SettingsDoc } from "./yaml-doc-store"
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
if (!speech) {
return next
}
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
if (rawApiKey) {
delete speech.apiKey
speech.hasApiKey = true
} else if (!("hasApiKey" in speech)) {
speech.hasApiKey = false
}
next.speech = speech
return next
}
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
if (owner !== "server") {
return value
}
return sanitizeServerOwner(value)
}
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
if (isPlainObject(next.server)) {
next.server = sanitizeServerOwner(next.server)
}
return next
}

View File

@@ -4,7 +4,6 @@ import type { ConfigLocation } from "../config/location"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate" import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types" import type { WorkspaceEventPayload } from "../api-types"
import { sanitizeConfigOwner } from "./public-config"
export type DocKind = "config" | "state" export type DocKind = "config" | "state"
@@ -46,11 +45,10 @@ export class SettingsService {
private publish(kind: DocKind, owner: string, value?: SettingsDoc) { private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
if (!this.eventBus) return if (!this.eventBus) return
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged" const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
const nextValue = value ?? this.getOwner(kind, owner)
const payload: WorkspaceEventPayload = { const payload: WorkspaceEventPayload = {
type, type,
owner, owner,
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue, value: value ?? this.getOwner(kind, owner),
} as any } as any
this.eventBus.publish(payload) this.eventBus.publish(payload)
} }

View File

@@ -1,234 +0,0 @@
import { Readable } from "node:stream"
import OpenAI from "openai"
import { toFile } from "openai/uploads"
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
import type { Logger } from "../../logger"
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
interface OpenAICompatibleSpeechProviderOptions {
settings: NormalizedSpeechSettings
logger: Logger
}
export class OpenAICompatibleSpeechProvider {
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
getCapabilities() {
const { settings } = this.options
return {
available: true,
configured: Boolean(settings.apiKey),
provider: settings.provider,
supportsStt: true,
supportsTts: true,
supportsStreamingTts: true,
baseUrl: settings.baseUrl,
sttModel: settings.sttModel,
ttsModel: settings.ttsModel,
ttsVoice: settings.ttsVoice,
ttsFormats: ["mp3", "wav", "opus", "aac"],
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
}
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
const client = this.createClient()
const startedAt = Date.now()
const extension = extensionForMime(input.mimeType)
const buffer = Buffer.from(input.audioBase64, "base64")
const filename = input.filename?.trim() || `prompt-input.${extension}`
this.options.logger.info(
{
mimeType: input.mimeType,
bytes: buffer.byteLength,
language: input.language,
model: this.options.settings.sttModel,
},
"speech.transcribe",
)
const response = await this.requestTranscription(client, buffer, filename, input)
return {
text: typeof response?.text === "string" ? response.text : "",
language: typeof response?.language === "string" ? response.language : input.language,
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
segments: Array.isArray(response?.segments)
? response.segments
.filter((segment: any) => typeof segment?.text === "string")
.map((segment: any) => ({
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
text: String(segment.text),
}))
: undefined,
}
}
private async requestTranscription(
client: OpenAI,
buffer: Buffer,
filename: string,
input: TranscribeAudioInput,
): Promise<any> {
const baseRequest = {
model: this.options.settings.sttModel,
...(input.language ? { language: input.language } : {}),
...(input.prompt ? { prompt: input.prompt } : {}),
}
try {
const file = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file,
response_format: "verbose_json" as any,
} as any)) as any
} catch (error) {
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file: retryFile,
} as any)) as any
}
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize",
)
const response = await this.requestSpeechAudio(input.text, format)
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
const audioBuffer = Buffer.from(await response.arrayBuffer())
return {
audioBase64: audioBuffer.toString("base64"),
mimeType,
}
}
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize.stream",
)
const response = await this.requestSpeechAudio(input.text, format)
if (!response.body) {
throw new Error("Speech provider did not return a stream.")
}
return {
stream: Readable.fromWeb(response.body as any),
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
}
}
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
let response: Response
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${settings.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: settings.ttsModel,
voice: settings.ttsVoice,
input: text,
response_format: format,
}),
})
} catch (error) {
const detailedError = error as Error & {
cause?: unknown
code?: string
errno?: number | string
syscall?: string
address?: string
port?: number
}
this.options.logger.error(
{
err: error,
endpoint: endpoint.toString(),
baseUrl: settings.baseUrl,
model: settings.ttsModel,
voice: settings.ttsVoice,
format,
cause: detailedError.cause,
code: detailedError.code,
errno: detailedError.errno,
syscall: detailedError.syscall,
address: detailedError.address,
port: detailedError.port,
},
"speech.synthesize fetch failed",
)
throw error
}
if (!response.ok) {
const detail = await response.text()
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
}
return response
}
private createClient(): OpenAI {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
return new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseUrl,
})
}
}
function extensionForMime(mimeType: string): string {
const normalized = mimeType.toLowerCase()
if (normalized.includes("webm")) return "webm"
if (normalized.includes("ogg")) return "ogg"
if (normalized.includes("wav")) return "wav"
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
return "webm"
}
function mimeTypeForFormat(format: "mp3" | "wav" | "opus" | "aac"): string {
if (format === "wav") return "audio/wav"
if (format === "opus") return 'audio/ogg; codecs="opus"'
if (format === "aac") return "audio/aac"
return "audio/mpeg"
}
function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`
}

View File

@@ -1,106 +0,0 @@
import { z } from "zod"
import type { Readable } from "node:stream"
import type { Logger } from "../logger"
import type { SettingsService } from "../settings/service"
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
const ServerSpeechSettingsSchema = z.object({
speech: z
.object({
provider: z.string().optional(),
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
sttModel: z.string().optional(),
ttsModel: z.string().optional(),
ttsVoice: z.string().optional(),
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
})
.optional(),
})
export interface TranscribeAudioInput {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}
export interface SynthesizeSpeechInput {
text: string
format?: "mp3" | "wav" | "opus" | "aac"
}
export interface SpeechSynthesisStreamResponse {
stream: Readable
mimeType: string
}
export interface SpeechProvider {
getCapabilities(): SpeechCapabilitiesResponse
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
}
export interface NormalizedSpeechSettings {
provider: string
apiKey?: string
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
ttsFormat: "mp3" | "wav" | "opus" | "aac"
}
const DEFAULT_PROVIDER = "openai-compatible"
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
const DEFAULT_TTS_VOICE = "alloy"
const DEFAULT_TTS_FORMAT = "mp3"
export class SpeechService {
constructor(
private readonly settings: SettingsService,
private readonly logger: Logger,
) {}
getCapabilities(): SpeechCapabilitiesResponse {
return this.createProvider().getCapabilities()
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
return this.createProvider().transcribe(input)
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
return this.createProvider().synthesize(input)
}
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
return this.createProvider().synthesizeStream(input)
}
private createProvider(): SpeechProvider {
const settings = this.resolveSettings()
return new OpenAICompatibleSpeechProvider({
settings,
logger: this.logger.child({ provider: settings.provider }),
})
}
private resolveSettings(): NormalizedSpeechSettings {
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
const speech = parsed.speech ?? {}
return {
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
}
}
}

View File

@@ -55,31 +55,4 @@ describe("resolveUi local version preference", () => {
assert.equal(result.uiStaticDir, bundledDir) assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1") assert.equal(result.uiVersion, "0.8.1")
}) })
it("prefers bundled when bundled and downloaded versions are equal", async () => {
const bundledDir = path.join(tempRoot, "bundled")
const configDir = path.join(tempRoot, "config")
const currentDir = path.join(configDir, "ui", "current")
await mkdir(bundledDir, { recursive: true })
await mkdir(currentDir, { recursive: true })
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
const result = await resolveUi({
serverVersion: "0.8.1",
bundledUiDir: bundledDir,
autoUpdate: false,
configDir,
logger: noopLogger,
})
assert.equal(result.source, "bundled")
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
}) })

View File

@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: currentResolved, uiStaticDir: currentResolved,
source: "downloaded", source: "downloaded",
uiVersion: await readUiVersion(currentResolved), uiVersion: await readUiVersion(currentResolved),
priority: 1, priority: 2,
}) })
} }
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: bundledResolved, uiStaticDir: bundledResolved,
source: "bundled", source: "bundled",
uiVersion: await readUiVersion(bundledResolved), uiVersion: await readUiVersion(bundledResolved),
priority: 2, priority: 1,
}) })
} }

View File

@@ -83,12 +83,6 @@ export class WorkspaceManager {
} }
} }
writeFile(workspaceId: string, relativePath: string, contents: string): void {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
browser.writeFile(relativePath, contents)
}
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> { async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}` const id = `${Date.now().toString(36)}`

View File

@@ -458,7 +458,7 @@ dependencies = [
[[package]] [[package]]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.13.3" version = "0.12.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dirs 5.0.1", "dirs 5.0.1",
@@ -473,7 +473,6 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -1351,16 +1350,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.4",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.1.16" version = "0.1.16"
@@ -1493,24 +1482,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "global-hotkey"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.18",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.18.0" version = "0.18.0"
@@ -4084,21 +4055,6 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.3.3" version = "2.3.3"
@@ -5779,29 +5735,6 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix 1.1.4",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.13.3", "version": "0.12.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -8,7 +8,6 @@
"dev:ui": "npm run dev --workspace @codenomad/ui", "dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js", "dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui", "dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"sync:version": "node ./scripts/sync-tauri-version.js",
"prebuild": "node ./scripts/prebuild.js", "prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild", "bundle:server": "npm run prebuild",
"build": "tauri build" "build": "tauri build"

View File

@@ -56,7 +56,11 @@ async function ensureMonacoAssets() {
function ensureServerBuild() { function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist") const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public") const publicPath = path.join(serverRoot, "public")
console.log("[prebuild] rebuilding server workspace for desktop packaging...") if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return
}
console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", { execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot, cwd: workspaceRoot,
stdio: "inherit", stdio: "inherit",

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..")
const packageJsonPath = path.join(root, "package.json")
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
const cargoLockPath = path.join(root, "Cargo.lock")
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
function readPackageVersion() {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("Missing version in packages/tauri-app/package.json")
}
return packageJson.version
}
function syncCargoToml(version) {
const current = fs.readFileSync(cargoTomlPath, "utf8")
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoTomlPath, updated)
return true
}
function syncCargoLock(version) {
if (!fs.existsSync(cargoLockPath)) {
return false
}
const current = fs.readFileSync(cargoLockPath, "utf8")
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoLockPath, updated)
return true
}
function syncTauriConfig(version) {
const current = fs.readFileSync(tauriConfigPath, "utf8")
const config = JSON.parse(current)
if (config.version === version) {
return false
}
config.version = version
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
return true
}
function main() {
const version = readPackageVersion()
const changed = []
if (syncCargoToml(version)) {
changed.push(path.relative(root, cargoTomlPath))
}
if (syncCargoLock(version)) {
changed.push(path.relative(root, cargoLockPath))
}
if (syncTauriConfig(version)) {
changed.push(path.relative(root, tauriConfigPath))
}
if (changed.length === 0) {
console.log(`[sync-tauri-version] already aligned to ${version}`)
return
}
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[sync-tauri-version] failed: ${message}`)
process.exit(1)
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.13.3" version = "0.12.3"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
@@ -23,7 +23,6 @@ keepawake = "0.6"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
dirs = "5" dirs = "5"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
url = "2" url = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
</dict>
</plist>

File diff suppressed because one or more lines are too long

View File

@@ -2378,72 +2378,6 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
"type": "string",
"const": "global-shortcut:default",
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
},
{
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Enables the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register-all",
"markdownDescription": "Enables the register_all command without any pre-configured scope."
},
{
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister",
"markdownDescription": "Enables the unregister command without any pre-configured scope."
},
{
"description": "Enables the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister-all",
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
},
{
"description": "Denies the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-is-registered",
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
},
{
"description": "Denies the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register",
"markdownDescription": "Denies the register command without any pre-configured scope."
},
{
"description": "Denies the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register-all",
"markdownDescription": "Denies the register_all command without any pre-configured scope."
},
{
"description": "Denies the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister",
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "Denies the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string", "type": "string",

View File

@@ -16,7 +16,7 @@ use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)] #[cfg(windows)]
@@ -48,11 +48,9 @@ fn workspace_root() -> Option<PathBuf> {
}) })
} }
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session"; const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30; const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(windows)]
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
#[cfg(unix)] #[cfg(unix)]
fn configure_posix_process_group(command: &mut Command) { fn configure_posix_process_group(command: &mut Command) {
@@ -124,11 +122,7 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
Some(value.to_string()) Some(value.to_string())
} }
fn exchange_bootstrap_token( fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
base_url: &str,
token: &str,
cookie_name: &str,
) -> anyhow::Result<Option<String>> {
let parsed = Url::parse(base_url)?; let parsed = Url::parse(base_url)?;
let host = parsed.host_str().unwrap_or("127.0.0.1"); let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port_or_known_default().unwrap_or(80); let port = parsed.port_or_known_default().unwrap_or(80);
@@ -163,11 +157,11 @@ fn exchange_bootstrap_token(
for line in lines { for line in lines {
// handle case-insensitive header name // handle case-insensitive header name
if let Some(value) = line.strip_prefix("Set-Cookie:") { if let Some(value) = line.strip_prefix("Set-Cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) { if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id)); return Ok(Some(session_id));
} }
} else if let Some(value) = line.strip_prefix("set-cookie:") { } else if let Some(value) = line.strip_prefix("set-cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) { if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id)); return Ok(Some(session_id));
} }
} }
@@ -176,16 +170,11 @@ fn exchange_bootstrap_token(
Ok(None) Ok(None)
} }
fn set_session_cookie( fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
app: &AppHandle,
base_url: &str,
cookie_name: &str,
session_id: &str,
) -> anyhow::Result<()> {
let parsed = Url::parse(base_url)?; let parsed = Url::parse(base_url)?;
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string(); let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string())) let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
.domain(domain) .domain(domain)
.path("/") .path("/")
.http_only(true) .http_only(true)
@@ -199,16 +188,6 @@ fn set_session_cookie(
Ok(()) Ok(())
} }
fn generate_auth_cookie_name() -> String {
let pid = std::process::id();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -423,8 +402,6 @@ impl CliProcessManager {
let mut child_opt = self.child.lock(); let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() { if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id())); log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(windows)]
let mut forced_tree_shutdown = false;
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
let pid = child.id() as i32; let pid = child.id() as i32;
@@ -437,7 +414,9 @@ impl CliProcessManager {
} }
#[cfg(windows)] #[cfg(windows)]
{ {
let _ = kill_process_tree_windows(child.id(), false); if !kill_process_tree_windows(child.id(), false) {
let _ = child.kill();
}
} }
let start = Instant::now(); let start = Instant::now();
@@ -445,21 +424,6 @@ impl CliProcessManager {
match child.try_wait() { match child.try_wait() {
Ok(Some(_)) => break, Ok(Some(_)) => break,
Ok(None) => { Ok(None) => {
#[cfg(windows)]
if !forced_tree_shutdown
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
{
log_line(&format!(
"regular Windows shutdown still running after {}ms; escalating pid={}",
CLI_WINDOWS_FORCE_GRACE_MS,
child.id()
));
forced_tree_shutdown = true;
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
log_line(&format!( log_line(&format!(
"stop timed out after {}s; sending SIGKILL pid={}", "stop timed out after {}s; sending SIGKILL pid={}",
@@ -476,11 +440,7 @@ impl CliProcessManager {
} }
#[cfg(windows)] #[cfg(windows)]
{ {
if !forced_tree_shutdown if !kill_process_tree_windows(child.id(), true) {
&& !kill_process_tree_windows(child.id(), true)
{
let _ = child.kill();
} else if forced_tree_shutdown {
let _ = child.kill(); let _ = child.kill();
} }
} }
@@ -522,8 +482,7 @@ impl CliProcessManager {
"resolved CLI entry runner={:?} entry={} host={}", "resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host resolution.runner, resolution.entry, host
)); ));
let auth_cookie_name = Arc::new(generate_auth_cookie_name()); let args = resolution.build_args(dev, &host);
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
log_line(&format!("CLI args: {:?}", args)); log_line(&format!("CLI args: {:?}", args));
if dev { if dev {
log_line("development mode: will prefer tsx + source if present"); log_line("development mode: will prefer tsx + source if present");
@@ -604,7 +563,6 @@ impl CliProcessManager {
let app_clone = app.clone(); let app_clone = app.clone();
let ready_clone = ready.clone(); let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone(); let token_clone = bootstrap_token.clone();
let auth_cookie_name_clone = auth_cookie_name.clone();
thread::spawn(move || { thread::spawn(move || {
let stdout = child_clone let stdout = child_clone
@@ -626,7 +584,6 @@ impl CliProcessManager {
&status_clone, &status_clone,
&ready_clone, &ready_clone,
&token_clone, &token_clone,
auth_cookie_name_clone.as_str(),
); );
} }
if let Some(reader) = stderr { if let Some(reader) = stderr {
@@ -637,7 +594,6 @@ impl CliProcessManager {
&status_clone, &status_clone,
&ready_clone, &ready_clone,
&token_clone, &token_clone,
auth_cookie_name_clone.as_str(),
); );
} }
}); });
@@ -754,7 +710,6 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>, status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>, ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>, bootstrap_token: &Arc<Mutex<Option<String>>>,
auth_cookie_name: &str,
) { ) {
let mut buffer = String::new(); let mut buffer = String::new();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok(); let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
@@ -790,14 +745,7 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
{ {
Self::mark_ready( Self::mark_ready(app, status, ready, bootstrap_token, url);
app,
status,
ready,
bootstrap_token,
auth_cookie_name,
url,
);
continue; continue;
} }
@@ -812,7 +760,6 @@ impl CliProcessManager {
status, status,
ready, ready,
bootstrap_token, bootstrap_token,
auth_cookie_name,
format!("http://localhost:{port}"), format!("http://localhost:{port}"),
); );
continue; continue;
@@ -825,7 +772,6 @@ impl CliProcessManager {
status, status,
ready, ready,
bootstrap_token, bootstrap_token,
auth_cookie_name,
format!("http://localhost:{}", port), format!("http://localhost:{}", port),
); );
continue; continue;
@@ -844,7 +790,6 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>, status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>, ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>, bootstrap_token: &Arc<Mutex<Option<String>>>,
auth_cookie_name: &str,
base_url: String, base_url: String,
) { ) {
ready.store(true, Ordering::SeqCst); ready.store(true, Ordering::SeqCst);
@@ -868,11 +813,9 @@ impl CliProcessManager {
if scheme.as_deref() != Some("http") { if scheme.as_deref() != Some("http") {
navigate_main(app, &base_url); navigate_main(app, &base_url);
} else { } else {
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) { match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => { Ok(Some(session_id)) => {
if let Err(err) = if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
{
log_line(&format!("failed to set session cookie: {err}")); log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login")); navigate_main(app, &format!("{base_url}/login"));
} else { } else {
@@ -968,13 +911,11 @@ impl CliEntry {
)) ))
} }
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> { fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![ let mut args = vec![
"serve".to_string(), "serve".to_string(),
"--host".to_string(), "--host".to_string(),
host.to_string(), host.to_string(),
"--auth-cookie-name".to_string(),
auth_cookie_name.to_string(),
"--generate-token".to_string(), "--generate-token".to_string(),
]; ];

View File

@@ -8,14 +8,10 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry}; use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use url::Url; use url::Url;
@@ -29,10 +25,6 @@ use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
#[cfg(windows)] #[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
@@ -40,7 +32,6 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
pub struct AppState { pub struct AppState {
pub manager: CliProcessManager, pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>, pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
@@ -166,83 +157,6 @@ fn emit_folder_drop_event(
} }
} }
fn clamp_zoom_level(value: f64) -> f64 {
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
}
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
if let Some(window) = app_handle.get_webview_window("main") {
let normalized = clamp_zoom_level(next_zoom);
if window.set_zoom(normalized).is_ok() {
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
*zoom_level = normalized;
}
}
}
}
fn reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.reload();
}
}
fn force_reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if let Ok(mut url) = window.url() {
if should_allow_internal(&url) {
let reload_token = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string();
let existing_pairs: Vec<(String, String)> = url
.query_pairs()
.into_owned()
.filter(|(key, _)| key != "__codenomad_force_reload")
.collect();
{
let mut pairs = url.query_pairs_mut();
pairs.clear();
for (key, value) in existing_pairs {
pairs.append_pair(&key, &value);
}
pairs.append_pair("__codenomad_force_reload", &reload_token);
}
let _ = window.navigate(url);
return;
}
}
let _ = window.reload();
}
}
fn toggle_fullscreen_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
let _ = window.set_fullscreen(next_fullscreen);
if cfg!(not(target_os = "macos")) {
if next_fullscreen {
let _ = window.hide_menu();
} else {
let _ = window.show_menu();
}
}
}
}
fn fullscreen_shortcut() -> Option<Shortcut> {
if cfg!(target_os = "macos") {
None
} else {
Some(Shortcut::new(None, ShortcutCode::F11))
}
}
#[cfg(windows)] #[cfg(windows)]
fn set_windows_app_user_model_id() { fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID) let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
@@ -267,48 +181,15 @@ fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state() != ShortcutState::Pressed {
return;
}
if fullscreen_shortcut().as_ref() == Some(shortcut) {
toggle_fullscreen_window(app);
}
})
.build(),
)
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(navigation_guard) .plugin(navigation_guard)
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),
wake_lock: Mutex::new(None), wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
}) })
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id(); set_windows_app_user_model_id();
build_menu(&app.handle())?; build_menu(&app.handle())?;
if let Some(shortcut) = fullscreen_shortcut() {
let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone());
if let Some(window) = app.get_webview_window("main") {
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(focused) = event {
let shortcut_manager = app_handle.global_shortcut();
if *focused {
let _ = shortcut_manager.register(shortcut.clone());
} else {
let _ = shortcut_manager.unregister(shortcut.clone());
}
}
});
}
}
let dev_mode = is_dev_mode(); let dev_mode = is_dev_mode();
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone(); let manager = app.state::<AppState>().manager.clone();
@@ -333,42 +214,36 @@ fn main() {
let _ = window.emit("menu:newInstance", ()); let _ = window.emit("menu:newInstance", ());
} }
} }
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => { "quit" => {
app_handle.exit(0); app_handle.exit(0);
} }
// View menu // View menu
"reload" => { "reload" => {
reload_main_window(app_handle); if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload()");
}
} }
"force_reload" => { "force_reload" => {
force_reload_main_window(app_handle); if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload(true)");
}
} }
"toggle_devtools" => { "toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") { if let Some(window) = app_handle.get_webview_window("main") {
if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools(); window.open_devtools();
} }
} }
}
"reset_zoom" => {
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
}
"zoom_in" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
}
}
"zoom_out" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
}
}
"toggle_fullscreen" => { "toggle_fullscreen" => {
toggle_fullscreen_window(app_handle); if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
} }
// Window menu // Window menu
@@ -382,11 +257,6 @@ fn main() {
let _ = window.maximize(); let _ = window.maximize();
} }
} }
"close_window" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
// App menu (macOS) // App menu (macOS)
"about" => { "about" => {
@@ -474,7 +344,6 @@ fn main() {
fn build_menu(app: &AppHandle) -> tauri::Result<()> { fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos"); let is_mac = cfg!(target_os = "macos");
let is_linux = cfg!(target_os = "linux");
// Create submenus // Create submenus
let mut submenus = Vec::new(); let mut submenus = Vec::new();
@@ -502,74 +371,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
Some("CmdOrCtrl+N"), Some("CmdOrCtrl+N"),
)?; )?;
let file_menu = if is_mac { let file_menu = SubmenuBuilder::new(app, "File")
SubmenuBuilder::new(app, "File")
.item(&new_instance_item) .item(&new_instance_item)
.separator() .separator()
.close_window() .text(
.build()? if is_mac { "close" } else { "quit" },
} else { if is_mac { "Close" } else { "Quit" },
SubmenuBuilder::new(app, "File") )
.item(&new_instance_item) .build()?;
.separator()
.text("quit", "Quit")
.build()?
};
submenus.push(file_menu); submenus.push(file_menu);
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
let force_reload_item = MenuItem::with_id(
app,
"force_reload",
"Force Reload",
true,
Some("CmdOrCtrl+Shift+R"),
)?;
let toggle_devtools_item = MenuItem::with_id(
app,
"toggle_devtools",
"Toggle Developer Tools",
true,
Some("Alt+CmdOrCtrl+I"),
)?;
let reset_zoom_item =
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
let zoom_in_item = MenuItem::with_id(
app,
"zoom_in",
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
true,
None::<&str>,
)?;
let zoom_out_item = MenuItem::with_id(
app,
"zoom_out",
if is_mac {
"Zoom Out"
} else {
"Zoom Out\tCtrl+-"
},
true,
None::<&str>,
)?;
let toggle_fullscreen_item = MenuItem::with_id(
app,
"toggle_fullscreen",
if is_mac {
"Toggle Full Screen"
} else {
"Toggle Full Screen\tF11"
},
true,
if is_mac {
Some("Ctrl+Cmd+F")
} else {
None::<&str>
},
)?;
let close_window_item =
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
// Edit menu with predefined items for standard functionality // Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit") let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo() .undo()
@@ -585,39 +396,20 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
// View menu // View menu
let view_menu = SubmenuBuilder::new(app, "View") let view_menu = SubmenuBuilder::new(app, "View")
.item(&reload_item) .text("reload", "Reload")
.item(&force_reload_item) .text("force_reload", "Force Reload")
.item(&toggle_devtools_item) .text("toggle_devtools", "Toggle Developer Tools")
.separator() .separator()
.item(&reset_zoom_item)
.item(&zoom_in_item)
.item(&zoom_out_item)
.separator() .separator()
.item(&toggle_fullscreen_item) .text("toggle_fullscreen", "Toggle Full Screen")
.build()?; .build()?;
submenus.push(view_menu); submenus.push(view_menu);
// Window menu // Window menu
let window_menu = if is_linux { let window_menu = SubmenuBuilder::new(app, "Window")
SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize") .text("minimize", "Minimize")
.text("zoom", "Zoom") .text("zoom", "Zoom")
.separator() .build()?;
.item(&close_window_item)
.build()?
} else if is_mac {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.build()?
} else {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.separator()
.close_window()
.build()?
};
submenus.push(window_menu); submenus.push(window_menu);
// Build the main menu with all submenus // Build the main menu with all submenus

View File

@@ -1,13 +1,16 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad", "productName": "CodeNomad",
"version": "0.13.3", "version": "0.12.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": [
@@ -30,13 +33,9 @@
], ],
"security": { "security": {
"assetProtocol": { "assetProtocol": {
"scope": [ "scope": ["**"]
"**"
]
}, },
"capabilities": [ "capabilities": ["main-window-native-dialogs"]
"main-window-native-dialogs"
]
} }
}, },
"bundle": { "bundle": {
@@ -45,17 +44,7 @@
"resources/server", "resources/server",
"resources/ui-loading" "resources/ui-loading"
], ],
"icon": [ "icon": ["icon.icns", "icon.ico", "icon.png"],
"icon.icns", "targets": ["app", "appimage", "deb", "rpm", "nsis"]
"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.3", "version": "0.12.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -68,7 +68,6 @@ const App: Component = () => {
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
@@ -354,7 +353,6 @@ const App: Component = () => {
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,

View File

@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
open open
modal modal
onOpenChange={(open) => { onOpenChange={(open) => {
// Only handle dismiss if dialog is dismissible (default: true) if (!open) {
if (!open && payload.dismissible !== false) {
dismiss(false, payload) dismiss(false, payload)
} }
}} }}
> >
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="modal-overlay z-[60]" /> <Dialog.Overlay class="modal-overlay" />
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold" class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
@@ -140,11 +140,10 @@ const AlertDialog: Component = () => {
<Show when={isPrompt}> <Show when={isPrompt}>
<div class="mt-4"> <div class="mt-4">
<label for="prompt-input" class="text-sm font-medium text-secondary"> <label class="text-sm font-medium text-secondary">
{payload.inputLabel || t("alertDialog.prompt.inputLabel")} {payload.inputLabel || t("alertDialog.prompt.inputLabel")}
</label> </label>
<input <input
id="prompt-input"
ref={(el) => { ref={(el) => {
promptInputRef = el promptInputRef = el
}} }}
@@ -187,6 +186,7 @@ const AlertDialog: Component = () => {
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>
</div>
</Dialog.Portal> </Dialog.Portal>
</Dialog> </Dialog>
) )

View File

@@ -1,4 +1,4 @@
import { createMemo, Show, createEffect } from "solid-js" import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid" import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import "@git-diff-view/solid/styles/diff-view-pure.css" import "@git-diff-view/solid/styles/diff-view-pure.css"
import { disableCache } from "@git-diff-view/core" import { disableCache } from "@git-diff-view/core"
@@ -20,7 +20,6 @@ interface ToolCallDiffViewerProps {
filePath?: string filePath?: string
theme: "light" | "dark" theme: "light" | "dark"
mode: DiffViewMode mode: DiffViewMode
wrap?: boolean
onRendered?: () => void onRendered?: () => void
cachedHtml?: string cachedHtml?: string
cacheEntryParams?: CacheEntryParams cacheEntryParams?: CacheEntryParams
@@ -32,183 +31,11 @@ type DiffData = {
hunks: string[] hunks: string[]
} }
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) { type CaptureContext = {
const computed = window.getComputedStyle(source) theme: ToolCallDiffViewerProps["theme"]
const probe = document.createElement("span") mode: DiffViewMode
probe.textContent = text || "" diffText: string
probe.style.position = "absolute" cacheEntryParams?: CacheEntryParams
probe.style.visibility = "hidden"
probe.style.pointerEvents = "none"
probe.style.display = "inline-block"
probe.style.width = "auto"
probe.style.maxWidth = "none"
probe.style.whiteSpace = "nowrap"
probe.style.fontFamily = computed.fontFamily
probe.style.fontSize = computed.fontSize
probe.style.fontWeight = computed.fontWeight
probe.style.fontStyle = computed.fontStyle
probe.style.letterSpacing = computed.letterSpacing
probe.style.fontVariant = computed.fontVariant
probe.style.textTransform = computed.textTransform
probe.style.lineHeight = computed.lineHeight
container.appendChild(probe)
const width = Math.ceil(probe.getBoundingClientRect().width)
probe.remove()
return width
}
function computeCompactWidth(
container: HTMLElement,
entries: Array<{ text: string; source: HTMLElement }>,
maxWidthPx = 40,
) {
const measuredLabelWidthPx = entries.reduce((max, entry) => {
return Math.max(max, measureTextWidth(container, entry.text, entry.source))
}, 0)
const fallbackTextLength = entries.reduce((max, entry) => Math.max(max, entry.text.length), 1)
const fallbackWidthPx = Math.round(fallbackTextLength * 7 + 4)
return Math.max(2, Math.min(maxWidthPx, measuredLabelWidthPx > 0 ? measuredLabelWidthPx + 2 : fallbackWidthPx))
}
function applyCompactUnifiedGutter(container: HTMLElement, wrap: boolean) {
const tableWrapper = container.querySelector<HTMLElement>(".unified-diff-table-wrapper")
const table = container.querySelector<HTMLTableElement>(".unified-diff-table")
const numberCol = container.querySelector<HTMLTableColElement>(".unified-diff-table-num-col")
const gutterRows = container.querySelectorAll<HTMLElement>(".diff-line-num")
const hunkGutters = container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper")
const entries: Array<{ gutter: HTMLElement; label: HTMLElement; text: string }> = []
if (table) {
if (wrap) {
table.classList.add("table-fixed")
table.style.tableLayout = "fixed"
table.style.width = "100%"
table.style.minWidth = "100%"
} else {
table.classList.remove("table-fixed")
table.style.tableLayout = "auto"
table.style.width = "max-content"
table.style.minWidth = "100%"
}
}
gutterRows.forEach((gutter) => {
const oldSpan = gutter.querySelector<HTMLElement>("[data-line-old-num]")
const newSpan = gutter.querySelector<HTMLElement>("[data-line-new-num]")
const spacer = gutter.querySelector<HTMLElement>(".shrink-0")
const flexWrapper = gutter.querySelector<HTMLElement>(":scope > .flex")
const currentLabel = gutter.querySelector<HTMLElement>(":scope > .tool-call-diff-compact-line-number")
const oldText = oldSpan?.textContent?.trim() ?? ""
const newText = newSpan?.textContent?.trim() ?? ""
const hasUsableNew = newText.length > 0 && newText !== "0"
const hasUsableOld = oldText.length > 0 && oldText !== "0"
const visibleText = hasUsableNew ? newText : hasUsableOld ? oldText : newText || oldText
if (flexWrapper) flexWrapper.style.display = "none"
if (spacer) spacer.style.display = "none"
if (oldSpan) { oldSpan.style.display = "none"; oldSpan.style.width = "auto" }
if (newSpan) { newSpan.style.display = "none"; newSpan.style.width = "auto" }
gutter.style.paddingLeft = "1px"
gutter.style.paddingRight = "1px"
gutter.style.textAlign = "left"
const label = currentLabel ?? document.createElement("span")
label.className = "tool-call-diff-compact-line-number"
label.textContent = visibleText
label.setAttribute("aria-hidden", visibleText ? "false" : "true")
if (!currentLabel) gutter.appendChild(label)
entries.push({ gutter, label, text: visibleText })
})
const gutterWidthPx = computeCompactWidth(container, entries.map((entry) => ({ text: entry.text, source: entry.label })))
const gutterWidth = `${gutterWidthPx}px`
const compactAsideWidth = `${Math.max(8, gutterWidthPx - 10)}px`
if (tableWrapper) {
tableWrapper.style.setProperty("--diff-aside-width", compactAsideWidth)
tableWrapper.style.setProperty("--diff-aside-width--", compactAsideWidth)
}
if (numberCol) {
numberCol.style.width = gutterWidth
}
entries.forEach(({ gutter, label }) => {
gutter.style.width = gutterWidth
gutter.style.minWidth = gutterWidth
gutter.style.maxWidth = gutterWidth
label.style.width = "auto"
label.style.maxWidth = "none"
})
hunkGutters.forEach((gutter) => {
gutter.style.width = gutterWidth
gutter.style.minWidth = gutterWidth
gutter.style.maxWidth = gutterWidth
gutter.style.paddingLeft = "0"
gutter.style.paddingRight = "0"
})
}
function applyCompactSplitGutter(container: HTMLElement) {
const oldWrapper = container.querySelector<HTMLElement>(".old-diff-table-wrapper")
const newWrapper = container.querySelector<HTMLElement>(".new-diff-table-wrapper")
const numberCells = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-old-num, .diff-line-new-num"))
const hunkActions = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper"))
const numberSpans = numberCells
.map((cell) => ({ cell, span: cell.querySelector<HTMLElement>("[data-line-num]") }))
.filter((entry): entry is { cell: HTMLElement; span: HTMLElement } => Boolean(entry.span))
const gutterWidthPx = computeCompactWidth(
container,
numberSpans.map(({ span }) => ({ text: span.textContent?.trim() ?? "", source: span })),
64,
)
const gutterWidth = `${gutterWidthPx}px`
;[oldWrapper, newWrapper].forEach((wrapper) => {
if (wrapper) {
wrapper.style.setProperty("--diff-aside-width", gutterWidth)
}
})
numberCells.forEach((cell) => {
cell.style.width = gutterWidth
cell.style.minWidth = gutterWidth
cell.style.maxWidth = gutterWidth
cell.style.paddingLeft = "2px"
cell.style.paddingRight = "2px"
cell.style.textAlign = "left"
cell.style.whiteSpace = "nowrap"
cell.style.overflowWrap = "normal"
cell.style.wordBreak = "normal"
})
numberSpans.forEach(({ span }) => {
span.style.whiteSpace = "nowrap"
span.style.overflowWrap = "normal"
span.style.wordBreak = "normal"
})
hunkActions.forEach((cell) => {
cell.style.width = gutterWidth
cell.style.minWidth = gutterWidth
cell.style.maxWidth = gutterWidth
cell.style.paddingLeft = "0"
cell.style.paddingRight = "0"
})
}
function applyCompactDiffLayout(container: HTMLElement, mode: DiffViewMode, wrap = false) {
if (mode === "unified") {
applyCompactUnifiedGutter(container, wrap)
return
}
if (mode === "split") {
applyCompactSplitGutter(container)
}
} }
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
@@ -240,15 +67,12 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
const contextKey = createMemo(() => { const contextKey = createMemo(() => {
const data = diffData() const data = diffData()
if (!data) return "" if (!data) return ""
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}` return `${props.theme}|${props.mode}|${props.diffText}`
}) })
createEffect(() => { createEffect(() => {
const cachedHtml = props.cachedHtml const cachedHtml = props.cachedHtml
if (cachedHtml) { if (cachedHtml) {
if (diffContainerRef) {
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
}
// When we are given cached HTML, we rely on the caller's cache // When we are given cached HTML, we rely on the caller's cache
// and simply notify once rendered. // and simply notify once rendered.
props.onRendered?.() props.onRendered?.()
@@ -262,7 +86,6 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!diffContainerRef) return if (!diffContainerRef) return
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
const markup = diffContainerRef.innerHTML const markup = diffContainerRef.innerHTML
if (!markup) return if (!markup) return
lastCapturedKey = key lastCapturedKey = key
@@ -272,7 +95,6 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
html: markup, html: markup,
theme: props.theme, theme: props.theme,
mode: props.mode, mode: props.mode,
wrap: props.wrap,
}) })
} }
props.onRendered?.() props.onRendered?.()
@@ -300,7 +122,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified} diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme} diffViewTheme={props.theme}
diffViewHighlight diffViewHighlight
diffViewWrap={Boolean(props.wrap)} diffViewWrap={false}
diffViewFontSize={13} diffViewFontSize={13}
/> />
</ErrorBoundary> </ErrorBoundary>
@@ -309,7 +131,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
</div> </div>
} }
> >
<div ref={diffContainerRef} innerHTML={props.cachedHtml} /> <div innerHTML={props.cachedHtml} />
</Show> </Show>
</div> </div>
) )

View File

@@ -9,8 +9,6 @@ interface MonacoFileViewerProps {
scopeKey: string scopeKey: string
path: string path: string
content: string content: string
onSave?: (content: string) => void
onContentChange?: (content: string) => void
} }
export function MonacoFileViewer(props: MonacoFileViewerProps) { export function MonacoFileViewer(props: MonacoFileViewerProps) {
@@ -35,11 +33,6 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = null editor = null
} }
const saveContent = () => {
if (!editor || !props.onSave) return
props.onSave(editor.getValue())
}
onMount(() => { onMount(() => {
let cancelled = false let cancelled = false
void (async () => { void (async () => {
@@ -51,7 +44,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = monaco.editor.create(host, { editor = monaco.editor.create(host, {
value: "", value: "",
language: "plaintext", language: "plaintext",
readOnly: false, readOnly: true,
automaticLayout: true, automaticLayout: true,
lineNumbers: "on", lineNumbers: "on",
minimap: { enabled: false }, minimap: { enabled: false },
@@ -61,14 +54,6 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
fontSize: 13, fontSize: 13,
}) })
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
editor.onDidChangeModelContent(() => {
if (props.onContentChange) {
props.onContentChange(editor.getValue())
}
})
setReady(true) setReady(true)
})() })()

View File

@@ -45,7 +45,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
{ value: "ru", label: "Русский" }, { value: "ru", label: "Русский" },
{ value: "ja", label: "日本語" }, { value: "ja", label: "日本語" },
{ value: "zh-Hans", label: "简体中文" }, { value: "zh-Hans", label: "简体中文" },
{ value: "he", label: "עברית" },
] ]
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0] const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
@@ -342,7 +341,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden" class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"} aria-busy={isLoading() ? "true" : "false"}
> >
<div class="absolute top-4" style="inset-inline-start: 1.5rem;"> <div class="absolute top-4 left-6">
<Select<LanguageOption> <Select<LanguageOption>
value={selectedLanguageOption()} value={selectedLanguageOption()}
onChange={(value) => { onChange={(value) => {
@@ -386,7 +385,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Select.Portal> </Select.Portal>
</Select> </Select>
</div> </div>
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;"> <div class="absolute top-4 right-6 flex items-center gap-2">
<button <button
type="button" type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -443,7 +442,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={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")} title={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

@@ -44,7 +44,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
variant: "warning", variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"), confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"), cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
dismissible: false,
}) })
if (!confirmed) return if (!confirmed) return
@@ -83,7 +82,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="panel-body space-y-3"> <div class="panel-body space-y-3">
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
<div dir="ltr" class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base"> <div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
{currentInstance().folder} {currentInstance().folder}
</div> </div>
</div> </div>
@@ -95,7 +94,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
{t("instanceInfo.labels.project")} {t("instanceInfo.labels.project")}
</div> </div>
<div dir="ltr" class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary"> <div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
{project().id} {project().id}
</div> </div>
</div> </div>
@@ -138,7 +137,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
{t("instanceInfo.labels.binaryPath")} {t("instanceInfo.labels.binaryPath")}
</div> </div>
<div dir="ltr" class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary"> <div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
{currentInstance().binaryPath} {currentInstance().binaryPath}
</div> </div>
</div> </div>
@@ -152,7 +151,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="space-y-1"> <div class="space-y-1">
<For each={environmentEntries()}> <For each={environmentEntries()}>
{([key, value]) => ( {([key, value]) => (
<div dir="ltr" class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base"> <div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}> <span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
{key} {key}
</span> </span>

View File

@@ -404,7 +404,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors" class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
dir="auto"
classList={{ classList={{
"text-accent": isFocused(), "text-accent": isFocused(),
}} }}

View File

@@ -36,12 +36,12 @@ import { serverApi } from "../../lib/api-client"
import { loadBackgroundProcesses } from "../../stores/background-processes" import { loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"
import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances" import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances"
import SessionSidebar from "./shell/SessionSidebar" 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 { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status" import { 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"
@@ -57,13 +57,6 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
import { useDrawerResize } from "./shell/useDrawerResize" import { useDrawerResize } from "./shell/useDrawerResize"
import { useSessionCache } from "./shell/useSessionCache" import { useSessionCache } from "./shell/useSessionCache"
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext" import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
import { getPermissionSessionId } from "../../types/permission"
import {
canAutoRespondPermission,
finishAutoRespondPermission,
getPermissionAutoAcceptInFlightVersion,
isPermissionAutoAcceptEnabled,
} from "../../stores/permission-auto-accept"
const log = getLogger("session") const log = getLogger("session")
@@ -88,8 +81,7 @@ interface InstanceShellProps {
} }
const InstanceShell2: Component<InstanceShellProps> = (props) => { const InstanceShell2: Component<InstanceShellProps> = (props) => {
const { t, locale } = useI18n() const { t } = useI18n()
const isRTL = () => locale() === "he"
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [rightDrawerWidth, setRightDrawerWidth] = createSignal( const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
@@ -104,7 +96,6 @@ 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)
@@ -238,12 +229,6 @@ 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()
@@ -266,33 +251,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return permissions + questions > 0 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 activeSessionStatusPill = createMemo(() => {
const activeSessionId = activeSessionIdForInstance() const activeSessionId = activeSessionIdForInstance()
if (!activeSessionId || activeSessionId === "info") return null if (!activeSessionId || activeSessionId === "info") return null
@@ -313,28 +271,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
} }
const status = getSessionStatus(props.instance.id, activeSessionId) const status = getSessionStatus(props.instance.id, activeSessionId)
const retry = getSessionRetry(props.instance.id, activeSessionId) const text =
const text = retry status === "working"
? (() => {
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-${retry ? "retrying" : status}`, className: `session-${status}`,
text, text,
showAlertIcon: false, showAlertIcon: false,
title: retry
? t("sessionList.status.retryTooltip", {
message: retry.message,
attempt: String(retry.attempt),
})
: undefined,
} }
}) })
@@ -342,39 +289,13 @@ 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}`} title={pill.title}> <span class={`status-indicator session-status session-status-list ${pill.className}`}>
{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>
) )
} }
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 = () => { const handleCommandPaletteClick = () => {
showCommandPalette(props.instance.id) showCommandPalette(props.instance.id)
} }
@@ -450,7 +371,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sx={{ sx={{
width: `${sessionSidebarWidth()}px`, width: `${sessionSidebarWidth()}px`,
flexShrink: 0, flexShrink: 0,
borderInlineEnd: "1px solid var(--border-base)", borderRight: "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)", backgroundColor: "var(--surface-secondary)",
height: "100%", height: "100%",
minHeight: 0, minHeight: 0,
@@ -492,17 +413,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const modalProps = container ? { container: container as Element } : undefined const modalProps = container ? { container: container as Element } : undefined
return ( return (
<Drawer <Drawer
anchor={isRTL() ? "right" : "left"} anchor="left"
variant="temporary" variant="temporary"
open={leftOpen()} open={leftOpen()}
onClose={closeLeftDrawer} onClose={closeLeftDrawer}
ModalProps={modalProps} ModalProps={modalProps}
sx={{ sx={{
zIndex: 60,
"& .MuiDrawer-paper": { "& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`, width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
boxSizing: "border-box", boxSizing: "border-box",
borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)", borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)", backgroundColor: "var(--surface-secondary)",
backgroundImage: "none", backgroundImage: "none",
color: "var(--text-primary)", color: "var(--text-primary)",
@@ -560,7 +480,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sx={{ sx={{
width: `${rightDrawerWidth()}px`, width: `${rightDrawerWidth()}px`,
flexShrink: 0, flexShrink: 0,
borderInlineStart: "1px solid var(--border-base)", borderLeft: "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)", backgroundColor: "var(--surface-secondary)",
height: "100%", height: "100%",
minHeight: 0, minHeight: 0,
@@ -603,17 +523,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const modalProps = container ? { container: container as Element } : undefined const modalProps = container ? { container: container as Element } : undefined
return ( return (
<Drawer <Drawer
anchor={isRTL() ? "left" : "right"} anchor="right"
variant="temporary" variant="temporary"
open={rightOpen()} open={rightOpen()}
onClose={closeRightDrawer} onClose={closeRightDrawer}
ModalProps={modalProps} ModalProps={modalProps}
sx={{ sx={{
zIndex: 60,
"& .MuiDrawer-paper": { "& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`, width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
boxSizing: "border-box", boxSizing: "border-box",
borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)", borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)", backgroundColor: "var(--surface-secondary)",
backgroundImage: "none", backgroundImage: "none",
color: "var(--text-primary)", color: "var(--text-primary)",
@@ -700,7 +619,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
<div class="flex-1 flex items-center justify-center min-w-0"> <div class="flex-1 flex items-center justify-center min-w-0">
{renderSessionHeaderIndicators()} <Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div> </div>
<div class="flex flex-wrap items-center justify-center gap-1"> <div class="flex flex-wrap items-center justify-center gap-1">
@@ -792,7 +716,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
<div class="ml-auto flex items-center session-header-hints"> <div class="ml-auto flex items-center session-header-hints">
{renderSessionHeaderIndicators()} <Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div> </div>
</div> </div>
@@ -813,7 +742,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
<div class="ms-auto flex items-center gap-3"> <div class="ml-auto flex items-center gap-3">
<div class="connection-status-meta flex items-center gap-3"> <div class="connection-status-meta flex items-center gap-3">
<Show when={connectionStatus() === "connected"}> <Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected"> <span class="status-indicator connected">

View File

@@ -145,6 +145,7 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<div class="session-sidebar-separator" /> <div class="session-sidebar-separator" />
<Show when={props.activeSession()}> <Show when={props.activeSession()}>
{(activeSession) => ( {(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3"> <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} /> <WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
@@ -176,10 +177,11 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
showDescription={false} showDescription={false}
/> />
</div> </div>
</>
)} )}
</Show> </Show>
</div> </div>
</div> </div>
) )
export default SessionSidebar export default SessionSidebar

View File

@@ -1,10 +1,8 @@
import { import {
Show, Show,
Suspense,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
lazy,
onCleanup, onCleanup,
type Accessor, type Accessor,
type Component, type Component,
@@ -22,11 +20,13 @@ import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types" import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import ChangesTab from "./tabs/ChangesTab"
import FilesTab from "./tabs/FilesTab"
import GitChangesTab from "./tabs/GitChangesTab"
import StatusTab from "./tabs/StatusTab"
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees" import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api" import { requestData } from "../../../../lib/opencode-api"
import { serverApi } from "../../../../lib/api-client"
import { showConfirmDialog } from "../../../../stores/alerts"
import { showToastNotification } from "../../../../lib/notifications"
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse" import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
import { useGlobalPointerDrag } from "../useGlobalPointerDrag" import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import { import {
@@ -49,15 +49,6 @@ import {
readStoredRightPanelTab, readStoredRightPanelTab,
} from "../storage" } from "../storage"
const LazyChangesTab = lazy(() => import("./tabs/ChangesTab"))
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
const LazyFilesTab = lazy(() => import("./tabs/FilesTab"))
const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
function RightPanelTabFallback() {
return <div class="flex-1 min-h-0" />
}
interface RightPanelProps { interface RightPanelProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -89,7 +80,6 @@ interface RightPanelProps {
const RightPanel: Component<RightPanelProps> = (props) => { const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes")) const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([ const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"yolo-mode",
"plan", "plan",
"background-processes", "background-processes",
"mcp", "mcp",
@@ -106,9 +96,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null) const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false) const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null) const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>( const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified", readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
@@ -256,8 +243,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const mode = activeSplitResize() const mode = activeSplitResize()
if (!mode) return if (!mode) return
event.preventDefault() event.preventDefault()
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" const delta = event.clientX - splitResizeStartX()
const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
const next = clampSplitWidth(splitResizeStartWidth() + delta) const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next) if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next) else if (mode === "git-changes") setGitChangesSplitWidth(next)
@@ -280,8 +266,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const touch = event.touches[0] const touch = event.touches[0]
if (!touch) return if (!touch) return
event.preventDefault() event.preventDefault()
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" const delta = touch.clientX - splitResizeStartX()
const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
const next = clampSplitWidth(splitResizeStartWidth() + delta) const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next) if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next) else if (mode === "git-changes") setGitChangesSplitWidth(next)
@@ -546,8 +531,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedLoading(true) setBrowserSelectedLoading(true)
setBrowserSelectedError(null) setBrowserSelectedError(null)
setBrowserSelectedContent(null) setBrowserSelectedContent(null)
setBrowserSelectedDirty(false)
setBrowserSelectedOriginalContent(null)
// Phone: treat file selection as a commit action and close the overlay. // Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) { if (props.isPhoneLayout()) {
@@ -568,7 +551,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
throw new Error("Unsupported file type") throw new Error("Unsupported file type")
} }
setBrowserSelectedContent(text) setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
} catch (error) { } catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally { } finally {
@@ -576,95 +558,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
} }
} }
const saveBrowserFile = async (content: string): Promise<boolean> => {
const path = browserSelectedPath()
if (!path) return false
// Check for conflict: agent edited file while user was editing
const originalContent = browserSelectedOriginalContent()
if (originalContent !== null) {
try {
const currentDiskContent = await requestData<FileContent>(
browserClient().file.read({ path }),
"file.read",
)
const diskContent = (currentDiskContent as any)?.content
// If disk content differs from what we originally loaded (agent edit)
// AND differs from user's current edits, we have a conflict
if (diskContent !== originalContent && diskContent !== content) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.conflict.message", { path }),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) {
return false
}
// User chose to overwrite, proceed with save
}
} catch {
// If we can't check for conflict, proceed with save
}
}
setBrowserSelectedSaving(true)
try {
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
setBrowserSelectedContent(content)
setBrowserSelectedOriginalContent(content) // Update original to match saved
setBrowserSelectedDirty(false)
showToastNotification({
message: props.t("instanceShell.rightPanel.toast.saveSuccess"),
variant: "success",
})
return true
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
showToastNotification({
message: props.t("instanceShell.rightPanel.toast.saveError"),
variant: "error",
})
return false
} finally {
setBrowserSelectedSaving(false)
}
}
const handleBrowserFileChange = (content: string) => {
setBrowserSelectedContent(content)
setBrowserSelectedDirty(true)
}
const handleOpenBrowserFileRequest = async (path: string) => {
if (browserSelectedDirty()) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
dismissible: false,
},
)
if (confirmed) {
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
if (!saveSuccess) {
// Save failed - stay on current file, error toast already shown
return
}
} else {
// User chose not to save - clear dirty state and discard edits
setBrowserSelectedDirty(false)
}
}
await openBrowserFile(path)
}
createEffect(() => { createEffect(() => {
if (rightPanelTab() !== "files") return if (rightPanelTab() !== "files") return
if (browserLoading()) return if (browserLoading()) return
@@ -672,14 +565,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
void loadBrowserEntries(browserPath()) void loadBrowserEntries(browserPath())
}) })
createEffect(() => {
if (rightPanelTab() === "files") return
setBrowserSelectedContent(null)
setBrowserSelectedLoading(false)
setBrowserSelectedError(null)
setBrowserSelectedDirty(false)
})
createEffect(() => { createEffect(() => {
if (rightPanelTab() !== "git-changes") return if (rightPanelTab() !== "git-changes") return
if (gitStatusLoading()) return if (gitStatusLoading()) return
@@ -687,14 +572,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
void loadGitStatus() void loadGitStatus()
}) })
createEffect(() => {
if (rightPanelTab() === "git-changes") return
setGitSelectedBefore(null)
setGitSelectedAfter(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
})
const handleSelectChangesFile = (file: string, closeList: boolean) => { const handleSelectChangesFile = (file: string, closeList: boolean) => {
setSelectedFile(file) setSelectedFile(file)
if (closeList) { if (closeList) {
@@ -730,22 +607,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
} }
const refreshFilesTab = async () => { const refreshFilesTab = async () => {
// Prompt for confirmation if file has unsaved changes
if (browserSelectedDirty()) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.refreshDirty.message"),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) {
return
}
}
void loadBrowserEntries(browserPath()) void loadBrowserEntries(browserPath())
const selected = browserSelectedPath() const selected = browserSelectedPath()
if (selected) { if (selected) {
@@ -767,8 +628,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
throw new Error("Unsupported file type") throw new Error("Unsupported file type")
} }
setBrowserSelectedContent(text) setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Update original content after refresh
setBrowserSelectedDirty(false) // Clear dirty after refresh
} catch (error) { } catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally { } finally {
@@ -788,7 +647,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setRightPanelTab("changes") setRightPanelTab("changes")
} }
const statusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"] const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
createEffect(() => { createEffect(() => {
const currentExpanded = new Set(rightPanelExpandedItems()) const currentExpanded = new Set(rightPanelExpandedItems())
@@ -879,8 +738,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<Show when={rightPanelTab() === "changes"}> <Show when={rightPanelTab() === "changes"}>
<Suspense fallback={<RightPanelTabFallback />}> <ChangesTab
<LazyChangesTab
t={props.t} t={props.t}
instanceId={props.instanceId} instanceId={props.instanceId}
activeSessionId={props.activeSessionId} activeSessionId={props.activeSessionId}
@@ -900,12 +758,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onResizeTouchStart={handleSplitResizeTouchStart("changes")} onResizeTouchStart={handleSplitResizeTouchStart("changes")}
isPhoneLayout={props.isPhoneLayout} isPhoneLayout={props.isPhoneLayout}
/> />
</Suspense>
</Show> </Show>
<Show when={rightPanelTab() === "git-changes"}> <Show when={rightPanelTab() === "git-changes"}>
<Suspense fallback={<RightPanelTabFallback />}> <GitChangesTab
<LazyGitChangesTab
t={props.t} t={props.t}
activeSessionId={props.activeSessionId} activeSessionId={props.activeSessionId}
entries={gitStatusEntries} entries={gitStatusEntries}
@@ -924,7 +780,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onViewModeChange={setDiffViewMode} onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode} onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode} onWordWrapModeChange={setDiffWordWrapMode}
onOpenFile={(path: string) => void openGitFile(path)} onOpenFile={(path) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()} onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen} listOpen={gitChangesListOpen}
onToggleList={toggleGitList} onToggleList={toggleGitList}
@@ -933,12 +789,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")} onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
isPhoneLayout={props.isPhoneLayout} isPhoneLayout={props.isPhoneLayout}
/> />
</Suspense>
</Show> </Show>
<Show when={rightPanelTab() === "files"}> <Show when={rightPanelTab() === "files"}>
<Suspense fallback={<RightPanelTabFallback />}> <FilesTab
<LazyFilesTab
t={props.t} t={props.t}
browserPath={browserPath} browserPath={browserPath}
browserEntries={browserEntries} browserEntries={browserEntries}
@@ -948,15 +802,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
browserSelectedContent={browserSelectedContent} browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading} browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError} browserSelectedError={browserSelectedError}
browserSelectedDirty={browserSelectedDirty}
browserSelectedSaving={browserSelectedSaving}
parentPath={browserParentPath} parentPath={browserParentPath}
scopeKey={browserScopeKey} scopeKey={browserScopeKey}
onLoadEntries={(path: string) => void loadBrowserEntries(path)} onLoadEntries={(path) => void loadBrowserEntries(path)}
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} onOpenFile={(path) => void openBrowserFile(path)}
onRefresh={() => void refreshFilesTab()} onRefresh={() => void refreshFilesTab()}
onSave={(content: string) => void saveBrowserFile(content)}
onContentChange={(content: string) => handleBrowserFileChange(content)}
listOpen={filesListOpen} listOpen={filesListOpen}
onToggleList={toggleFilesList} onToggleList={toggleFilesList}
splitWidth={filesSplitWidth} splitWidth={filesSplitWidth}
@@ -964,12 +814,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onResizeTouchStart={handleSplitResizeTouchStart("files")} onResizeTouchStart={handleSplitResizeTouchStart("files")}
isPhoneLayout={props.isPhoneLayout} isPhoneLayout={props.isPhoneLayout}
/> />
</Suspense>
</Show> </Show>
<Show when={rightPanelTab() === "status"}> <Show when={rightPanelTab() === "status"}>
<Suspense fallback={<RightPanelTabFallback />}> <StatusTab
<LazyStatusTab
t={props.t} t={props.t}
instanceId={props.instanceId} instanceId={props.instanceId}
instance={props.instance} instance={props.instance}
@@ -985,7 +833,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onExpandedItemsChange={handleAccordionChange} onExpandedItemsChange={handleAccordionChange}
onOpenChangesTab={openChangesTabFromStatus} onOpenChangesTab={openChangesTabFromStatus}
/> />
</Suspense>
</Show> </Show>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,6 @@ import type { Component } from "solid-js"
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid" import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
import { useI18n } from "../../../../../lib/i18n"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface DiffToolbarProps { interface DiffToolbarProps {
@@ -15,15 +14,14 @@ interface DiffToolbarProps {
} }
const DiffToolbar: Component<DiffToolbarProps> = (props) => { const DiffToolbar: Component<DiffToolbarProps> = (props) => {
const { t } = useI18n()
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split") const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed") const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on") const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
const viewModeTitle = () => (nextViewMode() === "split" ? t("instanceShell.diff.switchToSplit") : t("instanceShell.diff.switchToUnified")) const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
const contextModeTitle = () => const contextModeTitle = () =>
nextContextMode() === "collapsed" ? t("instanceShell.diff.hideUnchanged") : t("instanceShell.diff.showFull") nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? t("instanceShell.diff.enableWordWrap") : t("instanceShell.diff.disableWordWrap")) const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
return ( return (
<div class="file-viewer-toolbar"> <div class="file-viewer-toolbar">

View File

@@ -1,6 +1,5 @@
import { Show, type Component, type JSX } from "solid-js" import { Show, type Component, type JSX } from "solid-js"
import { useI18n } from "../../../../../lib/i18n"
import OverlayList from "./OverlayList" import OverlayList from "./OverlayList"
type SplitFilePanelList = { type SplitFilePanelList = {
@@ -25,13 +24,12 @@ interface SplitFilePanelProps {
} }
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => { const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
const { t } = useI18n()
return ( return (
<div class="files-tab-container"> <div class="files-tab-container">
<div class="files-tab-header"> <div class="files-tab-header">
<div class="files-tab-header-row"> <div class="files-tab-header-row">
<button type="button" class="files-toggle-button" onClick={props.onToggleList}> <button type="button" class="files-toggle-button" onClick={props.onToggleList}>
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")} {props.listOpen ? "Hide files" : "Show files"}
</button> </button>
{props.header} {props.header}

View File

@@ -1,7 +1,7 @@
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client" import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw, Save } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
@@ -21,17 +21,13 @@ interface FilesTabProps {
browserSelectedContent: Accessor<string | null> browserSelectedContent: Accessor<string | null>
browserSelectedLoading: Accessor<boolean> browserSelectedLoading: Accessor<boolean>
browserSelectedError: Accessor<string | null> browserSelectedError: Accessor<string | null>
browserSelectedDirty: Accessor<boolean>
browserSelectedSaving: Accessor<boolean>
parentPath: Accessor<string | null> parentPath: Accessor<string | null>
scopeKey: Accessor<string> scopeKey: Accessor<string>
onLoadEntries: (path: string) => void onLoadEntries: (path: string) => void
onRequestOpenFile: (path: string) => void onOpenFile: (path: string) => void
onRefresh: () => void onRefresh: () => void
onSave: (content: string) => void
onContentChange: (content: string) => void
listOpen: Accessor<boolean> listOpen: Accessor<boolean>
onToggleList: () => void onToggleList: () => void
@@ -42,13 +38,6 @@ interface FilesTabProps {
} }
const FilesTab: Component<FilesTabProps> = (props) => { const FilesTab: Component<FilesTabProps> = (props) => {
const handleSave = () => {
const content = props.browserSelectedContent()
if (content !== undefined && content !== null) {
props.onSave(content)
}
}
const renderContent = (): JSX.Element => { const renderContent = (): JSX.Element => {
const entriesValue = props.browserEntries() const entriesValue = props.browserEntries()
const entries = entriesValue || [] const entries = entriesValue || []
@@ -97,13 +86,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</div> </div>
} }
> >
<LazyMonacoFileViewer <LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
scopeKey={props.scopeKey()}
path={payload().path}
content={payload().content}
onSave={props.onSave}
onContentChange={props.onContentChange}
/>
</Suspense> </Suspense>
)} )}
</Show> </Show>
@@ -152,7 +135,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
props.onLoadEntries(item.path) props.onLoadEntries(item.path)
return return
} }
props.onRequestOpenFile(item.path) props.onOpenFile(item.path)
}} }}
title={item.path} title={item.path}
> >
@@ -185,25 +168,14 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show> </Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show> <Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div> </div>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
style={{ "margin-inline-start": "auto" }}
onClick={handleSave}
>
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>
<RefreshCw class="h-4 w-4 animate-spin" />
</Show>
</button>
<button <button
type="button" type="button"
class="files-header-icon-button" class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")} title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")} aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.browserLoading()} disabled={props.browserLoading()}
style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()} onClick={() => props.onRefresh()}
> >
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} /> <RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />

View File

@@ -82,7 +82,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}) })
const emptyViewerMessage = createMemo(() => { const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected") if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
const currentEntries = entries() const currentEntries = entries()
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading") if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty") if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")

View File

@@ -2,7 +2,6 @@ import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk/v2"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip" import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
@@ -13,7 +12,6 @@ import type { Session } from "../../../../../types/session"
import ContextUsagePanel from "../../../../session/context-usage-panel" import ContextUsagePanel from "../../../../session/context-usage-panel"
import { TodoListView } from "../../../../tool-call/renderers/todo" import { TodoListView } from "../../../../tool-call/renderers/todo"
import InstanceServiceStatus from "../../../../instance-service-status" import InstanceServiceStatus from "../../../../instance-service-status"
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
interface StatusTabProps { interface StatusTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -41,35 +39,6 @@ interface StatusTabProps {
const StatusTab: Component<StatusTabProps> = (props) => { const StatusTab: Component<StatusTabProps> = (props) => {
const isSectionExpanded = (id: string) => props.expandedItems().includes(id) 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 renderStatusSessionChanges = () => {
const sessionId = props.activeSessionId() const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") { if (!sessionId || sessionId === "info") {
@@ -235,12 +204,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
} }
const statusSections = [ const statusSections = [
{
id: "yolo-mode",
labelKey: "instanceShell.rightPanel.sections.yoloMode",
tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
render: renderYoloModeSection,
},
{ {
id: "session-changes", id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges", labelKey: "instanceShell.rightPanel.sections.sessionChanges",
@@ -318,23 +281,29 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<For each={statusSections}> <For each={statusSections}>
{(section) => ( {(section) => (
<Accordion.Item value={section.id} class="right-panel-accordion-item"> <Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header class="right-panel-accordion-header-row"> <Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger"> <Accordion.Trigger class="right-panel-accordion-trigger">
<span class="section-left"> <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 class="section-label">{props.t(section.labelKey)}</span>
</span> </span>
<ChevronDown <ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`} class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/> />
</Accordion.Trigger> </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.Header>
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content> <Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
</Accordion.Item> </Accordion.Item>

View File

@@ -46,9 +46,7 @@ export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
if (!side) return if (!side) return
const startWidth = resizeStartWidth() const startWidth = resizeStartWidth()
const clamp = side === "left" ? options.clampLeft : options.clampRight const clamp = side === "left" ? options.clampLeft : options.clampRight
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const rawDelta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const delta = isRtl ? -rawDelta : rawDelta
const nextWidth = clamp(startWidth + delta) const nextWidth = clamp(startWidth + delta)
applyDrawerWidth(side, nextWidth) applyDrawerWidth(side, nextWidth)
} }

View File

@@ -83,7 +83,6 @@ interface MarkdownProps {
isDark?: boolean isDark?: boolean
size?: "base" | "sm" | "tight" size?: "base" | "sm" | "tight"
disableHighlight?: boolean disableHighlight?: boolean
escapeRawHtml?: boolean
onRendered?: () => void onRendered?: () => void
} }
@@ -104,12 +103,11 @@ export function Markdown(props: MarkdownProps) {
const text = decodeHtmlEntitiesLocally(rawText) const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light" const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
const cacheId = resolvePartCacheId(part, text) const cacheId = resolvePartCacheId(part, text)
const version = resolvePartVersion(part, text) const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}` const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey } return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
}) })
const cacheHandle = useGlobalCache({ const cacheHandle = useGlobalCache({
@@ -118,26 +116,20 @@ export function Markdown(props: MarkdownProps) {
scope: "markdown", scope: "markdown",
cacheId: () => { cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved() const { cacheId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}` return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
}, },
version: () => resolved().version, version: () => resolved().version,
}) })
const commitCacheEntry = ( const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
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,
theme: snapshot.themeKey, theme: snapshot.themeKey,
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`, mode: snapshot.version,
} }
setHtml(renderedHtml) setHtml(renderedHtml)
if (options?.cache ?? true) {
cacheHandle.set(cacheEntry) cacheHandle.set(cacheEntry)
}
notifyRendered() notifyRendered()
} }
@@ -146,23 +138,20 @@ export function Markdown(props: MarkdownProps) {
markdown.setMarkdownTheme(snapshot.themeKey === "dark") markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, { const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled, suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml,
}) })
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
if (latestRequestKey === snapshot.requestKey) { if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(snapshot, rendered, { cache: shouldCache }) commitCacheEntry(snapshot, rendered)
} }
} }
createEffect(() => { createEffect(() => {
const snapshot = resolved() const snapshot = resolved()
latestRequestKey = snapshot.requestKey latestRequestKey = snapshot.requestKey
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
const cacheMatches = (cache: RenderCache | undefined) => { const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false if (!cache) return false
return cache.theme === snapshot.themeKey && cache.mode === cacheMode return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
} }
const localCache = snapshot.part.renderCache const localCache = snapshot.part.renderCache
@@ -255,7 +244,6 @@ export function Markdown(props: MarkdownProps) {
<div <div
ref={containerRef} ref={containerRef}
class="markdown-body" class="markdown-body"
dir="auto"
data-view="markdown" data-view="markdown"
data-part-id={resolved().partId} data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey} data-markdown-theme={resolved().themeKey}

View File

@@ -1,6 +1,7 @@
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message" import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
@@ -14,8 +15,6 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover" import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() { function DeleteUpToIcon() {
return ( return (
@@ -30,12 +29,6 @@ const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)" const TOOL_BORDER_COLOR = "var(--message-tool-border)"
const LazyToolCall = lazy(() => import("./tool-call"))
function ToolCallFallback() {
return <div class="tool-call tool-call-loading" />
}
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -507,8 +500,7 @@ function ToolCallItem(props: ToolCallItemProps) {
</div> </div>
</div> </div>
<Suspense fallback={<ToolCallFallback />}> <ToolCall
<LazyToolCall
toolCall={resolvedToolPart()} toolCall={resolvedToolPart()}
toolCallId={props.partId} toolCallId={props.partId}
messageId={props.messageId} messageId={props.messageId}
@@ -518,7 +510,6 @@ function ToolCallItem(props: ToolCallItemProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</Suspense>
</div> </div>
)} )}
</Show> </Show>
@@ -911,7 +902,6 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds} selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/> />
</Match> </Match>
</Switch> </Switch>
@@ -1290,7 +1280,6 @@ interface ReasoningCardProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void> onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string> selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onContentRendered?: () => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
@@ -1299,25 +1288,6 @@ function ReasoningCard(props: ReasoningCardProps) {
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
let pendingRenderNotificationFrame: number | null = null
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
@@ -1386,19 +1356,6 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () => const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view") expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
text: reasoningText,
})
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {
@@ -1471,20 +1428,6 @@ function ReasoningCard(props: ReasoningCardProps) {
</button> </button>
<div class="message-reasoning-actions"> <div class="message-reasoning-actions">
<Show when={canSpeakReasoning()}>
<SpeechActionButton
class="message-action-button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void speech.toggle()
}}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<button <button
type="button" type="button"
class="message-action-button" class="message-action-button"
@@ -1554,7 +1497,7 @@ function ReasoningCard(props: ReasoningCardProps) {
<div class="message-reasoning-expanded"> <div class="message-reasoning-expanded">
<div class="message-reasoning-body"> <div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}> <div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre> <pre class="message-reasoning-text">{reasoningText() || ""}</pre>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -11,8 +11,6 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env" import { isTauriHost } from "../lib/runtime-env"
import type { DeleteHoverState } from "../types/delete-hover" import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() { function DeleteUpToIcon() {
return ( return (
@@ -296,13 +294,6 @@ export default function MessageItem(props: MessageItemProps) {
.join("\n\n") .join("\n\n")
} }
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.record.id}`,
text: getRawContent,
})
const canSpeakMessage = () => getRawContent().trim().length > 0 && speech.canUseSpeech()
const handleCopy = async () => { const handleCopy = async () => {
const content = getRawContent() const content = getRawContent()
if (!content) return if (!content) return
@@ -452,16 +443,6 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> <Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
<Show when={canSpeakMessage()}>
<SpeechActionButton
class="message-action-button"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<Show when={props.onFork}> <Show when={props.onFork}>
<button <button
class="message-action-button" class="message-action-button"
@@ -522,16 +503,6 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> <Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
<Show when={canSpeakMessage()}>
<SpeechActionButton
class="message-action-button"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<Show when={props.showDeleteMessage}> <Show when={props.showDeleteMessage}>
<button <button
class="message-action-button" class="message-action-button"
@@ -571,7 +542,7 @@ export default function MessageItem(props: MessageItemProps) {
</header> </header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto"> <div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={props.isQueued && isUser()}> <Show when={props.isQueued && isUser()}>
@@ -579,7 +550,7 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
<Show when={errorMessage()}> <Show when={errorMessage()}>
<div class="message-error-block" dir="auto"> {errorMessage()}</div> <div class="message-error-block"> {errorMessage()}</div>
</Show> </Show>
<Show when={isGenerating()}> <Show when={isGenerating()}>

View File

@@ -1,4 +1,5 @@
import { Match, Show, Suspense, Switch, lazy } from "solid-js" import { Show, Match, Switch } from "solid-js"
import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state" import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown" import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
@@ -6,8 +7,6 @@ import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/m
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
const LazyToolCall = lazy(() => import("./tool-call"))
interface MessagePartProps { interface MessagePartProps {
part: ClientPart part: ClientPart
messageType?: "user" | "assistant" messageType?: "user" | "assistant"
@@ -134,19 +133,17 @@ export default function MessagePart(props: MessagePartProps) {
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div <div
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
dir="auto"
data-role={textContainerRole()} data-role={textContainerRole()}
data-part-type="text" data-part-type="text"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined} data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
> >
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}> <Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
<Markdown <Markdown
part={createTextPartForMarkdown()} part={createTextPartForMarkdown()}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
isDark={isDark()} isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"} size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered} onRendered={props.onRendered}
/> />
</Show> </Show>
@@ -155,14 +152,12 @@ export default function MessagePart(props: MessagePartProps) {
</Match> </Match>
<Match when={partType() === "tool"}> <Match when={partType() === "tool"}>
<Suspense fallback={<div class="tool-call tool-call-loading" />}> <ToolCall
<LazyToolCall
toolCall={props.part as ToolCallPart} toolCall={props.part as ToolCallPart}
toolCallId={props.part?.id} toolCallId={props.part?.id}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
/> />
</Suspense>
</Match> </Match>

View File

@@ -19,7 +19,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" import { getPartCharCount } from "../lib/token-utils"
const SCROLL_SENTINEL_MARGIN_PX = 8 const SCROLL_SENTINEL_MARGIN_PX = 48
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000 const QUOTE_SELECTION_MAX_LENGTH = 2000
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href

View File

@@ -295,7 +295,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })} {t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
</span> </span>
{currentModelValue() && ( {currentModelValue() && (
<span class="selector-trigger-secondary" dir="ltr"> <span class="selector-trigger-secondary">
{currentModelValue()!.providerId}/{currentModelValue()!.id} {currentModelValue()!.providerId}/{currentModelValue()!.id}
</span> </span>
)} )}

View File

@@ -1,4 +1,4 @@
import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, onCleanup, type Component } from "solid-js" import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
import type { PermissionRequestLike } from "../types/permission" import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
@@ -12,8 +12,7 @@ import {
} from "../stores/instances" } from "../stores/instances"
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import ToolCall from "./tool-call"
const LazyToolCall = lazy(() => import("./tool-call"))
interface PermissionApprovalModalProps { interface PermissionApprovalModalProps {
instanceId: string instanceId: string
@@ -409,8 +408,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
} }
> >
{(data) => ( {(data) => (
<Suspense fallback={<div class="tool-call tool-call-loading" />}> <ToolCall
<LazyToolCall
toolCall={data().toolPart} toolCall={data().toolPart}
toolCallId={data().toolPart.id} toolCallId={data().toolPart.id}
messageId={data().messageId} messageId={data().messageId}
@@ -419,7 +417,6 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={data().sessionId} sessionId={data().sessionId}
/> />
</Suspense>
)} )}
</Show> </Show>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js" import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Volume2, X } from "lucide-solid" import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments" import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
@@ -18,15 +19,7 @@ import { usePromptState } from "./prompt-input/usePromptState"
import { usePromptAttachments } from "./prompt-input/usePromptAttachments" 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 {
canUseConversationMode,
clearConversationPlaybackForInstance,
isConversationModeEnabled,
toggleConversationMode,
} from "../stores/conversation-speech"
const log = getLogger("actions") const log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] { function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
if (!text || attachments.length === 0) return [] if (!text || attachments.length === 0) return []
@@ -357,19 +350,6 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus() textareaRef?.focus()
} }
function handleClearPrompt() {
clearPrompt()
clearHistoryDraft()
resetHistoryNavigation()
setShowPicker(false)
setPickerMode("mention")
setAtPosition(null)
setSearchQuery("")
setIgnoredAtPositions(new Set<number>())
syncAttachmentCounters("")
textareaRef?.focus()
}
function insertBlockContent(block: string) { function insertBlockContent(block: string) {
const textarea = textareaRef const textarea = textareaRef
const current = prompt() const current = prompt()
@@ -441,8 +421,6 @@ export default function PromptInput(props: PromptInputProps) {
return hasText || attachments().length > 0 return hasText || attachments().length > 0
} }
const canClearPrompt = () => prompt().length > 0
const shellHint = () => const shellHint = () =>
mode() === "shell" mode() === "shell"
? { key: "Esc", text: t("promptInput.hints.shell.exit") } ? { key: "Esc", text: t("promptInput.hints.shell.exit") }
@@ -472,54 +450,9 @@ export default function PromptInput(props: PromptInputProps) {
}) })
const shouldShowOverlay = () => prompt().length === 0 const shouldShowOverlay = () => prompt().length === 0
const voiceInput = usePromptVoiceInput({
prompt,
setPrompt,
getTextarea: () => textareaRef ?? null,
enabled: () => preferences().showPromptVoiceInput,
disabled: () => Boolean(props.disabled),
})
const showVoiceInput = () =>
preferences().showPromptVoiceInput &&
(voiceInput.canUseVoiceInput() || voiceInput.isRecording() || voiceInput.isTranscribing())
const conversationModeEnabled = () => isConversationModeEnabled(props.instanceId)
const showConversationToggle = () => showVoiceInput() || conversationModeEnabled()
const canToggleConversationMode = () => canUseConversationMode()
const conversationModeButtonTitle = () =>
conversationModeEnabled()
? t("promptInput.conversationMode.disable.title")
: t("promptInput.conversationMode.enable.title")
const instance = () => getActiveInstance() const instance = () => getActiveInstance()
let voiceButtonPressed = false
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
if (target instanceof HTMLElement) {
try {
target.setPointerCapture(event.pointerId)
} catch {
// no-op
}
}
}
void voiceInput.startRecording()
}
const endVoicePress = () => {
if (!voiceButtonPressed) return
voiceButtonPressed = false
voiceInput.stopRecording()
}
return ( return (
<div class="prompt-input-container"> <div class="prompt-input-container">
<div <div
@@ -534,8 +467,7 @@ export default function PromptInput(props: PromptInputProps) {
onDrop={handleDrop} onDrop={handleDrop}
> >
<Show when={showPicker() && instance()}> <Show when={showPicker() && instance()}>
<Suspense fallback={null}> <UnifiedPicker
<LazyUnifiedPicker
open={showPicker()} open={showPicker()}
mode={pickerMode()} mode={pickerMode()}
onClose={handlePickerClose} onClose={handlePickerClose}
@@ -547,7 +479,6 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef={textareaRef} textareaRef={textareaRef}
workspaceId={props.instanceId} workspaceId={props.instanceId}
/> />
</Suspense>
</Show> </Show>
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
@@ -557,7 +488,6 @@ export default function PromptInput(props: PromptInputProps) {
<textarea <textarea
ref={textareaRef} ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`} class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
dir="auto"
placeholder={getPlaceholder()} placeholder={getPlaceholder()}
value={prompt()} value={prompt()}
onInput={handleInput} onInput={handleInput}
@@ -573,74 +503,6 @@ export default function PromptInput(props: PromptInputProps) {
autocomplete="off" autocomplete="off"
/> />
<div class="prompt-nav-buttons"> <div class="prompt-nav-buttons">
<div class="prompt-nav-column prompt-nav-column-left">
<Show when={showVoiceInput()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<Mic class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
</Show>
<Show when={showConversationToggle()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
onClick={() => toggleConversationMode(props.instanceId)}
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
aria-pressed={conversationModeEnabled()}
aria-label={conversationModeButtonTitle()}
title={conversationModeButtonTitle()}
>
<Volume2 class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="prompt-clear-button"
onClick={handleClearPrompt}
disabled={!canClearPrompt()}
aria-label={t("promptInput.clear.ariaLabel")}
title={t("promptInput.clear.title")}
>
<X class="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div class="prompt-nav-column prompt-nav-column-right">
<ExpandButton <ExpandButton
expandState={expandState} expandState={expandState}
onToggleExpand={handleExpandToggle} onToggleExpand={handleExpandToggle}
@@ -678,7 +540,6 @@ export default function PromptInput(props: PromptInputProps) {
</button> </button>
</Show> </Show>
</div> </div>
</div>
<Show when={shouldShowOverlay()}> <Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}> <div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show <Show

View File

@@ -1,253 +0,0 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { showAlertDialog } from "../../stores/alerts"
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
import { serverApi } from "../../lib/api-client"
import { useI18n } from "../../lib/i18n"
import { isElectronHost } from "../../lib/runtime-env"
interface UsePromptVoiceInputOptions {
prompt: Accessor<string>
setPrompt: (value: string) => void
getTextarea: () => HTMLTextAreaElement | null
enabled: Accessor<boolean>
disabled: Accessor<boolean>
}
type VoiceInputState = "idle" | "recording" | "transcribing"
export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const { t } = useI18n()
const [state, setState] = createSignal<VoiceInputState>("idle")
const [elapsedMs, setElapsedMs] = createSignal(0)
let mediaRecorder: MediaRecorder | null = null
let mediaStream: MediaStream | null = null
let timerId: number | undefined
let shouldTranscribe = true
let recordedChunks: Blob[] = []
let recordingStartedAt = 0
createEffect(() => {
void loadSpeechCapabilities()
})
onCleanup(() => {
cleanupMedia(false)
})
const isSupported = () => {
if (typeof window === "undefined") return false
return typeof window.MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia)
}
const canUseVoiceInput = () => {
const capabilities = speechCapabilities()
return Boolean(
options.enabled() &&
isSupported() &&
capabilities?.available &&
capabilities?.configured &&
capabilities?.supportsStt,
)
}
async function toggleRecording(): Promise<void> {
if (state() === "recording") {
stopRecording()
return
}
await startRecording()
}
function stopRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = true
mediaRecorder.stop()
setState("transcribing")
stopTimer()
}
function cancelRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = false
mediaRecorder.stop()
cleanupMedia(false)
}
async function startRecording() {
if (!canUseVoiceInput() || options.disabled() || state() === "transcribing" || state() === "recording") return
if (!isSupported()) {
showAlertDialog(t("promptInput.voiceInput.error.unsupported"), {
title: t("promptInput.voiceInput.error.title"),
variant: "error",
})
return
}
try {
recordedChunks = []
shouldTranscribe = true
if (isElectronHost()) {
const granted = await (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.requestMicrophoneAccess?.()
if (granted && !granted.granted) {
throw new Error(t("promptInput.voiceInput.error.permissionDenied"))
}
}
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = createRecorder(mediaStream)
mediaRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data)
}
})
mediaRecorder.addEventListener("stop", () => {
void finalizeRecording()
})
recordingStartedAt = Date.now()
setElapsedMs(0)
setState("recording")
startTimer()
mediaRecorder.start()
} catch (error) {
cleanupMedia(false)
showAlertDialog(t("promptInput.voiceInput.error.permission"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
}
async function finalizeRecording() {
const recorder = mediaRecorder
const stream = mediaStream
mediaRecorder = null
mediaStream = null
if (!shouldTranscribe || recordedChunks.length === 0) {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
return
}
const mimeType = recorder?.mimeType || recordedChunks[0]?.type || "audio/webm"
try {
const audioBlob = new Blob(recordedChunks, { type: mimeType })
const transcription = await serverApi.transcribeAudio({
audioBase64: await blobToBase64(audioBlob),
mimeType,
})
if (transcription.text.trim()) {
insertTranscript(transcription.text.trim())
}
} catch (error) {
showAlertDialog(t("promptInput.voiceInput.error.transcribe"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
}
}
function insertTranscript(text: string) {
const current = options.prompt()
const textarea = options.getTextarea()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const before = current.slice(0, start)
const after = current.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length
options.setPrompt(nextValue)
if (textarea) {
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
}, 0)
}
}
function cleanupMedia(resetState = true) {
stopTimer()
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop()
}
mediaRecorder = null
stopTracks(mediaStream)
mediaStream = null
recordedChunks = []
if (resetState) {
setState("idle")
setElapsedMs(0)
}
}
function startTimer() {
stopTimer()
timerId = window.setInterval(() => {
setElapsedMs(Date.now() - recordingStartedAt)
}, 250)
}
function stopTimer() {
if (timerId !== undefined) {
window.clearInterval(timerId)
timerId = undefined
}
}
return {
state,
elapsedMs,
canUseVoiceInput,
startRecording,
stopRecording,
toggleRecording,
cancelRecording,
isRecording: () => state() === "recording",
isTranscribing: () => state() === "transcribing",
buttonTitle: () => {
if (state() === "recording") return t("promptInput.voiceInput.stop.title")
if (state() === "transcribing") return t("promptInput.voiceInput.transcribing.title")
return t("promptInput.voiceInput.start.title")
},
}
}
function createRecorder(stream: MediaStream): MediaRecorder {
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
const supported = candidates.find((candidate) => typeof MediaRecorder.isTypeSupported !== "function" || MediaRecorder.isTypeSupported(candidate))
return supported ? new MediaRecorder(stream, { mimeType: supported }) : new MediaRecorder(stream)
}
function stopTracks(stream: MediaStream | null) {
stream?.getTracks().forEach((track) => track.stop())
}
async function blobToBase64(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer()
const bytes = new Uint8Array(buffer)
let binary = ""
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}

View File

@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
import { Switch } from "@kobalte/core/switch" import { Switch } from "@kobalte/core/switch"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js" import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { toDataURL } from "qrcode" import { toDataURL } from "qrcode"
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { restartCli } from "../lib/native/cli" import { restartCli } from "../lib/native/cli"
@@ -10,7 +10,6 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts" import { showConfirmDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
const log = getLogger("actions") const log = getLogger("actions")
@@ -33,17 +32,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [passwordConfirm, setPasswordConfirm] = createSignal("") const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null) const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false) const [savingPassword, setSavingPassword] = createSignal(false)
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? []) const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all") const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo<RemoteAddressGroups>(() => { const displayAddresses = createMemo(() => {
const list = addresses() const list = addresses()
if (!allowExternalConnections()) { if (!allowExternalConnections()) {
return { recommended: null, hidden: [] } return []
} }
return splitRemoteAddresses(list) // Local URL is displayed separately; list only remote-friendly addresses.
return list.filter((address) => address.scope !== "loopback")
}) })
const refreshMeta = async () => { const refreshMeta = async () => {
@@ -54,7 +53,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult) setMeta(metaResult)
setAuthStatus(authResult) setAuthStatus(authResult)
setShowAllAddresses(false)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
} finally { } finally {
@@ -100,7 +98,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
variant: "warning", variant: "warning",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"), confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"), cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
dismissible: false,
}) })
if (!confirmed) { if (!confirmed) {
@@ -328,7 +325,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}> <Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}> <Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}> <Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
<div class="remote-address-list"> <div class="remote-address-list">
<Show when={meta()?.localUrl}> <Show when={meta()?.localUrl}>
{(url) => { {(url) => {
@@ -375,81 +372,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
) )
}} }}
</Show> </Show>
<Show when={displayAddresses().recommended}> <For each={displayAddresses()}>
{(addressAccessor) => {
const address = addressAccessor()
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</Show>
<Show when={displayAddresses().hidden.length > 0}>
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
<button
class="remote-address-disclosure-trigger"
type="button"
onClick={() => setShowAllAddresses(!showAllAddresses())}
aria-expanded={showAllAddresses()}
>
<span class="remote-address-disclosure-label">
{showAllAddresses()
? t("remoteAccess.addresses.actions.hideOther")
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
</span>
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
</button>
<Show when={showAllAddresses()}>
<div class="remote-address-disclosure-content">
<For each={displayAddresses().hidden}>
{(address) => { {(address) => {
const url = address.remoteUrl const url = address.remoteUrl
const expandedState = () => expandedUrl() === url const expandedState = () => expandedUrl() === url
@@ -504,10 +427,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
</For> </For>
</div> </div>
</Show> </Show>
</div>
</Show>
</div>
</Show>
</Show> </Show>
</Show> </Show>
</section> </section>

View File

@@ -1,8 +1,8 @@
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 { getRetrySeconds, getSessionRetry, getSessionStatus } from "../stores/session-status" import { 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 } 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"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
@@ -14,7 +14,6 @@ import {
ensureSessionParentExpanded, ensureSessionParentExpanded,
getVisibleSessionIds, getVisibleSessionIds,
isSessionParentExpanded, isSessionParentExpanded,
loadMessages,
loading, loading,
renameSession, renameSession,
sessions as sessionStateSessions, sessions as sessionStateSessions,
@@ -54,14 +53,6 @@ const SessionList: Component<SessionListProps> = (props) => {
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : "")) const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
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 [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)
@@ -166,7 +157,6 @@ const SessionList: Component<SessionListProps> = (props) => {
variant: "warning", variant: "warning",
confirmLabel: t("sessionList.delete.confirmLabel"), confirmLabel: t("sessionList.delete.confirmLabel"),
cancelLabel: t("sessionList.delete.cancelLabel"), cancelLabel: t("sessionList.delete.cancelLabel"),
dismissible: false,
}, },
) )
if (!confirmed) return if (!confirmed) return
@@ -222,32 +212,6 @@ const SessionList: Component<SessionListProps> = (props) => {
setRenameTarget({ id: sessionId, title: session.title ?? "", label }) setRenameTarget({ id: sessionId, title: session.title ?? "", label })
} }
const isSessionReloading = (sessionId: string) => reloadingSessionIds().has(sessionId)
const handleReloadSession = async (event: MouseEvent, sessionId: string) => {
event.stopPropagation()
if (isSessionReloading(sessionId)) return
setReloadingSessionIds((prev) => {
const next = new Set(prev)
next.add(sessionId)
return next
})
try {
await loadMessages(props.instanceId, sessionId, true)
} catch (error) {
log.error(`Failed to reload session ${sessionId}:`, error)
showToastNotification({ message: t("sessionList.reload.error"), variant: "error" })
} finally {
setReloadingSessionIds((prev) => {
const next = new Set(prev)
next.delete(sessionId)
return next
})
}
}
const closeRenameDialog = () => { const closeRenameDialog = () => {
setRenameTarget(null) setRenameTarget(null)
} }
@@ -321,7 +285,6 @@ const SessionList: Component<SessionListProps> = (props) => {
variant: "warning", variant: "warning",
confirmLabel: t("sessionList.bulkDelete.confirmLabel"), confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
cancelLabel: t("sessionList.bulkDelete.cancelLabel"), cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
dismissible: false,
}, },
) )
@@ -407,13 +370,7 @@ 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")
@@ -426,21 +383,13 @@ 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-${retry() ? "retrying" : status()}`) const statusClassName = () => (needsInput() ? "session-permission" : `session-${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)
@@ -495,7 +444,7 @@ const SessionList: Component<SessionListProps> = (props) => {
</Show> </Show>
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />} {rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
<span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span> <span class="session-item-title session-item-title--clamp">{title()}</span>
</div> </div>
</div> </div>
<div class="session-item-row session-item-meta"> <div class="session-item-row session-item-meta">
@@ -520,7 +469,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()}`} title={statusTooltip()}> <span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
{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>
@@ -542,21 +491,6 @@ const SessionList: Component<SessionListProps> = (props) => {
> >
<Copy class="w-3 h-3" /> <Copy class="w-3 h-3" />
</span> </span>
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => handleReloadSession(event, rowProps.sessionId)}
role="button"
tabIndex={0}
aria-label={t("sessionList.actions.reload.ariaLabel")}
title={t("sessionList.actions.reload.title")}
>
<Show
when={!isSessionReloading(rowProps.sessionId)}
fallback={<RotateCw class="w-3 h-3 animate-spin" />}
>
<RotateCw class="w-3 h-3" />
</Show>
</span>
<span <span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`} class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => { onClick={(event) => {

View File

@@ -76,7 +76,6 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
inputRef = element inputRef = element
}} }}
type="text" type="text"
dir="auto"
value={title()} value={title()}
onInput={(event) => setTitle(event.currentTarget.value)} onInput={(event) => setTitle(event.currentTarget.value)}
placeholder={t("sessionRenameDialog.input.placeholder")} placeholder={t("sessionRenameDialog.input.placeholder")}

View File

@@ -16,7 +16,6 @@ import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api" import { requestData } from "../../lib/opencode-api"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types" import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
import { clearConversationPlaybackForSession } from "../../stores/conversation-speech"
const log = getLogger("session") const log = getLogger("session")
@@ -89,10 +88,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
on( on(
() => props.isActive, () => props.isActive,
(isActive) => { (isActive) => {
if (!isActive) {
clearConversationPlaybackForSession(props.instanceId, props.sessionId)
return
}
if (!isActive) return if (!isActive) return
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK). // On phones, focusing the prompt on session switch is disruptive (it raises the OSK).

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid" import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js" import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { import {
@@ -13,7 +13,6 @@ import { AppearanceSettingsSection } from "./settings/appearance-settings-sectio
import { NotificationsSettingsSection } from "./settings/notifications-settings-section" import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section"
export const SettingsScreen: Component = () => { export const SettingsScreen: Component = () => {
const { t } = useI18n() const { t } = useI18n()
@@ -22,7 +21,6 @@ export const SettingsScreen: Component = () => {
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") }, { id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") }, { id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") }, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, { id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
]) ])
@@ -32,8 +30,6 @@ export const SettingsScreen: Component = () => {
return <NotificationsSettingsSection /> return <NotificationsSettingsSection />
case "remote": case "remote":
return <RemoteAccessSettingsSection /> return <RemoteAccessSettingsSection />
case "speech":
return <SpeechSettingsSection />
case "opencode": case "opencode":
return <OpenCodeSettingsSection /> return <OpenCodeSettingsSection />
case "appearance": case "appearance":

View File

@@ -24,7 +24,6 @@ export const AppearanceSettingsSection: Component = () => {
toggleUsageMetrics, toggleUsageMetrics,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
@@ -42,7 +41,6 @@ export const AppearanceSettingsSection: Component = () => {
toggleUsageMetrics, toggleUsageMetrics,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,

View File

@@ -1,7 +1,7 @@
import { Switch } from "@kobalte/core/switch" import { Switch } from "@kobalte/core/switch"
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js" import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
import { toDataURL } from "qrcode" import { toDataURL } from "qrcode"
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types" import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
import { serverApi } from "../../lib/api-client" import { serverApi } from "../../lib/api-client"
import { restartCli } from "../../lib/native/cli" import { restartCli } from "../../lib/native/cli"
@@ -9,7 +9,6 @@ import { serverSettings, setListeningMode } from "../../stores/preferences"
import { showConfirmDialog } from "../../stores/alerts" import { showConfirmDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"
import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses"
const log = getLogger("actions") const log = getLogger("actions")
@@ -31,15 +30,14 @@ export const RemoteAccessSettingsSection: Component = () => {
const [passwordConfirm, setPasswordConfirm] = createSignal("") const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null) const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false) const [savingPassword, setSavingPassword] = createSignal(false)
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? []) const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all") const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo<RemoteAddressGroups>(() => { const displayAddresses = createMemo(() => {
const list = addresses() const list = addresses()
if (!allowExternalConnections()) return { recommended: null, hidden: [] } if (!allowExternalConnections()) return []
return splitRemoteAddresses(list) return list.filter((address) => address.scope !== "loopback")
}) })
const refreshMeta = async () => { const refreshMeta = async () => {
@@ -50,7 +48,6 @@ export const RemoteAccessSettingsSection: Component = () => {
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult) setMeta(metaResult)
setAuthStatus(authResult) setAuthStatus(authResult)
setShowAllAddresses(false)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
} finally { } finally {
@@ -89,7 +86,6 @@ export const RemoteAccessSettingsSection: Component = () => {
variant: "warning", variant: "warning",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"), confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"), cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
dismissible: false,
}) })
if (!confirmed) return if (!confirmed) return
@@ -221,15 +217,12 @@ export const RemoteAccessSettingsSection: Component = () => {
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>} fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
> >
<div class="settings-card-content"> <div class="settings-card-content">
<div class="settings-password-summary-row">
<div class="settings-password-summary-copy">
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p> <p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
<p class="settings-help-text"> <p class="settings-help-text">
{authStatus()!.passwordUserProvided {authStatus()!.passwordUserProvided
? t("remoteAccess.password.status.set") ? t("remoteAccess.password.status.set")
: t("remoteAccess.password.status.unset")} : t("remoteAccess.password.status.unset")}
</p> </p>
</div>
<div class="settings-password-actions"> <div class="settings-password-actions">
<button <button
@@ -247,7 +240,6 @@ export const RemoteAccessSettingsSection: Component = () => {
: t("remoteAccess.password.actions.set")} : t("remoteAccess.password.actions.set")}
</button> </button>
</div> </div>
</div>
<Show when={passwordFormOpen()}> <Show when={passwordFormOpen()}>
<div class="settings-form-group"> <div class="settings-form-group">
@@ -299,7 +291,7 @@ export const RemoteAccessSettingsSection: Component = () => {
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}> <Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}> <Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show <Show
when={Boolean(displayAddresses().recommended) || meta()?.localUrl} when={displayAddresses().length > 0 || meta()?.localUrl}
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
> >
<div class="remote-address-list"> <div class="remote-address-list">
@@ -349,81 +341,7 @@ export const RemoteAccessSettingsSection: Component = () => {
}} }}
</Show> </Show>
<Show when={displayAddresses().recommended}> <For each={displayAddresses()}>
{(addressAccessor) => {
const address = addressAccessor()
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</Show>
<Show when={displayAddresses().hidden.length > 0}>
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
<button
class="remote-address-disclosure-trigger"
type="button"
onClick={() => setShowAllAddresses(!showAllAddresses())}
aria-expanded={showAllAddresses()}
>
<span class="remote-address-disclosure-label">
{showAllAddresses()
? t("remoteAccess.addresses.actions.hideOther")
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
</span>
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
</button>
<Show when={showAllAddresses()}>
<div class="remote-address-disclosure-content">
<For each={displayAddresses().hidden}>
{(address) => { {(address) => {
const url = address.remoteUrl const url = address.remoteUrl
const expandedState = () => expandedUrl() === url const expandedState = () => expandedUrl() === url
@@ -475,10 +393,6 @@ export const RemoteAccessSettingsSection: Component = () => {
</For> </For>
</div> </div>
</Show> </Show>
</div>
</Show>
</div>
</Show>
</Show> </Show>
</Show> </Show>
</div> </div>

View File

@@ -1,373 +0,0 @@
import { For, Show, createEffect, createMemo, createSignal, type Component } from "solid-js"
import { Loader2, Mic, Square, Volume2 } from "lucide-solid"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech"
import { getLogger } from "../../lib/logger"
import { useSpeech } from "../../lib/hooks/use-speech"
import { getSpeechPlaybackSupport } from "../../lib/speech-playback-support"
const log = getLogger("actions")
type DraftFields = {
apiKey: string
baseUrl: string
sttModel: string
ttsModel: string
ttsVoice: string
playbackMode: SpeechSettings["playbackMode"]
ttsFormat: SpeechSettings["ttsFormat"]
}
function createDraftFields(speech: SpeechSettings): DraftFields {
return {
apiKey: "",
baseUrl: speech.baseUrl ?? "",
sttModel: speech.sttModel,
ttsModel: speech.ttsModel,
ttsVoice: speech.ttsVoice,
playbackMode: speech.playbackMode,
ttsFormat: speech.ttsFormat,
}
}
function isDraftEqual(a: DraftFields, b: DraftFields): boolean {
return (
a.apiKey === b.apiKey &&
a.baseUrl === b.baseUrl &&
a.sttModel === b.sttModel &&
a.ttsModel === b.ttsModel &&
a.ttsVoice === b.ttsVoice &&
a.playbackMode === b.playbackMode &&
a.ttsFormat === b.ttsFormat
)
}
export const SpeechSettingsCard: Component = () => {
const { t } = useI18n()
const { serverSettings, updateSpeechSettings } = useConfig()
const initialDrafts = createDraftFields(serverSettings().speech)
const [isSaving, setIsSaving] = createSignal(false)
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
const [apiKeyTouched, setApiKeyTouched] = createSignal(false)
const [clearStoredApiKey, setClearStoredApiKey] = createSignal(false)
const testSpeech = useSpeech({
id: () => "settings-speech-test",
text: () => t("settings.speech.testPlayback.sample"),
settingsOverride: () => ({
playbackMode: drafts().playbackMode,
ttsFormat: drafts().ttsFormat,
}),
})
createEffect(() => {
const speech = serverSettings().speech
const nextDrafts = createDraftFields(speech)
if (!isSaving() && !isDirty()) {
if (!isDraftEqual(drafts(), nextDrafts)) {
setDrafts(nextDrafts)
}
if (apiKeyTouched()) {
setApiKeyTouched(false)
}
if (clearStoredApiKey()) {
setClearStoredApiKey(false)
}
}
})
createEffect(() => {
void loadSpeechCapabilities()
})
const capabilityLabel = () => {
if (speechCapabilitiesLoading()) return t("settings.speech.status.loading")
if (speechCapabilitiesError()) return t("settings.speech.status.error")
return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing")
}
const updateDraft = (key: keyof DraftFields, value: string) => {
setSaveStatus("idle")
if (key === "apiKey") {
setApiKeyTouched(true)
setClearStoredApiKey(false)
}
setDrafts((current) => ({ ...current, [key]: value }))
}
const apiKeyDirty = createMemo(() => clearStoredApiKey() || drafts().apiKey.trim().length > 0)
const playbackSupport = createMemo(() =>
getSpeechPlaybackSupport({
playbackMode: drafts().playbackMode,
ttsFormat: drafts().ttsFormat,
capabilities: speechCapabilities(),
}),
)
const compatibilityMessage = createMemo(() => {
const capabilities = speechCapabilities()
if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
return null
}
if (drafts().playbackMode === "streaming" && !capabilities.supportsStreamingTts) {
return t("settings.speech.compatibility.streamingUnavailable")
}
if (drafts().playbackMode === "streaming" && !playbackSupport().available) {
return t("settings.speech.compatibility.browserStreamingUnavailable")
}
return t("settings.speech.compatibility.runtimeNote")
})
const isDirty = createMemo(() => {
const speech = serverSettings().speech
const current = drafts()
return (
apiKeyDirty() ||
(current.baseUrl || "") !== (speech.baseUrl || "") ||
current.sttModel !== speech.sttModel ||
current.ttsModel !== speech.ttsModel ||
current.ttsVoice !== speech.ttsVoice ||
current.playbackMode !== speech.playbackMode ||
current.ttsFormat !== speech.ttsFormat
)
})
const saveStatusLabel = () => {
if (isSaving()) return t("settings.speech.save.saving")
if (saveStatus() === "saved") return t("settings.speech.save.saved")
if (saveStatus() === "error") return t("settings.speech.save.error")
return t("settings.speech.save.unsaved")
}
async function handleSave() {
if (!isDirty() || isSaving()) return
const current = drafts()
setIsSaving(true)
setSaveStatus("idle")
try {
const trimmedApiKey = current.apiKey.trim()
await updateSpeechSettings({
...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}),
baseUrl: current.baseUrl.trim() || undefined,
sttModel: current.sttModel.trim() || undefined,
ttsModel: current.ttsModel.trim() || undefined,
ttsVoice: current.ttsVoice.trim() || undefined,
playbackMode: current.playbackMode,
ttsFormat: current.ttsFormat,
})
await loadSpeechCapabilities(true)
setDrafts({
apiKey: "",
baseUrl: current.baseUrl.trim(),
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
playbackMode: current.playbackMode,
ttsFormat: current.ttsFormat,
})
setApiKeyTouched(false)
setClearStoredApiKey(false)
setSaveStatus("saved")
} catch (error) {
log.error("Failed to save speech settings", error)
setSaveStatus("error")
} finally {
setIsSaving(false)
}
}
return (
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Volume2 class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.speech.title")}</h3>
<p class="settings-card-subtitle">{t("settings.speech.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.speech.provider.title")}</div>
<div class="settings-toggle-caption">{t("settings.speech.provider.subtitle")}</div>
</div>
<div class="settings-toolbar-inline">
<span class="settings-inline-note">{t("settings.speech.provider.openaiCompatible")}</span>
<span class="settings-inline-note">{capabilityLabel()}</span>
<span class="settings-inline-note">{saveStatusLabel()}</span>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap inline-flex items-center gap-2"
onClick={() => void testSpeech.toggle()}
disabled={isSaving()}
title={testSpeech.buttonTitle()}
aria-label={testSpeech.buttonTitle()}
>
<Show
when={testSpeech.isLoading()}
fallback={
<Show when={testSpeech.isPlaying()} fallback={<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />}>
<Square class="w-3.5 h-3.5" aria-hidden="true" />
</Show>
}
>
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
</Show>
<span>
{testSpeech.isPlaying()
? t("settings.speech.testPlayback.stop")
: testSpeech.isLoading()
? t("settings.speech.testPlayback.generating")
: t("settings.speech.testPlayback.action")}
</span>
</button>
<button
type="button"
class="selector-button selector-button-primary w-auto whitespace-nowrap"
onClick={() => void handleSave()}
disabled={!isDirty() || isSaving()}
>
{isSaving() ? t("settings.speech.save.saving") : t("settings.speech.save.action")}
</button>
</div>
</div>
<Field
label={t("settings.speech.apiKey.title")}
caption={t("settings.speech.apiKey.subtitle")}
value={drafts().apiKey}
onInput={(value) => updateDraft("apiKey", value)}
type="password"
placeholder={serverSettings().speech.hasApiKey ? t("settings.speech.apiKey.placeholder") : undefined}
/>
<Show when={serverSettings().speech.hasApiKey && !apiKeyTouched() && drafts().apiKey.length === 0}>
<div class="settings-inline-note">
{clearStoredApiKey() ? t("settings.speech.apiKey.clearPending") : t("settings.speech.apiKey.storedNote")}{" "}
<Show when={!clearStoredApiKey()}>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => {
setClearStoredApiKey(true)
setSaveStatus("idle")
}}
>
{t("settings.speech.apiKey.clearAction")}
</button>
</Show>
</div>
</Show>
<Field
label={t("settings.speech.baseUrl.title")}
caption={t("settings.speech.baseUrl.subtitle")}
value={drafts().baseUrl}
onInput={(value) => updateDraft("baseUrl", value)}
placeholder={t("settings.speech.baseUrl.placeholder")}
/>
<Field
label={t("settings.speech.sttModel.title")}
caption={t("settings.speech.sttModel.subtitle")}
value={drafts().sttModel}
onInput={(value) => updateDraft("sttModel", value)}
/>
<Field
label={t("settings.speech.ttsModel.title")}
caption={t("settings.speech.ttsModel.subtitle")}
value={drafts().ttsModel}
onInput={(value) => updateDraft("ttsModel", value)}
/>
<Field
label={t("settings.speech.ttsVoice.title")}
caption={t("settings.speech.ttsVoice.subtitle")}
value={drafts().ttsVoice}
onInput={(value) => updateDraft("ttsVoice", value)}
icon={<Mic class="w-3.5 h-3.5 icon-muted flex-shrink-0" />}
/>
<SelectField
label={t("settings.speech.playbackMode.title")}
caption={t("settings.speech.playbackMode.subtitle")}
value={drafts().playbackMode}
onInput={(value) => updateDraft("playbackMode", value as DraftFields["playbackMode"])}
options={[
{ value: "streaming", label: t("settings.speech.playbackMode.streaming") },
{ value: "buffered", label: t("settings.speech.playbackMode.buffered") },
]}
/>
<SelectField
label={t("settings.speech.ttsFormat.title")}
caption={t("settings.speech.ttsFormat.subtitle")}
value={drafts().ttsFormat}
onInput={(value) => updateDraft("ttsFormat", value as DraftFields["ttsFormat"])}
options={[
{ value: "mp3", label: "MP3" },
{ value: "wav", label: "WAV" },
{ value: "opus", label: "Opus" },
{ value: "aac", label: "AAC" },
]}
/>
<div class="settings-inline-note">{t("settings.speech.help")}</div>
<Show when={compatibilityMessage()}>{(message) => <div class="settings-inline-note">{message()}</div>}</Show>
<div class="settings-inline-note">{t("settings.speech.testPlayback.note")}</div>
</div>
</div>
)
}
const Field: Component<{
label: string
caption: string
value: string
type?: string
placeholder?: string
onInput: (value: string) => void
icon?: any
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
{props.icon}
<input
type={props.type ?? "text"}
value={props.value}
onInput={(event) => props.onInput(event.currentTarget.value)}
class="selector-input w-full"
placeholder={props.placeholder}
/>
</div>
</div>
)
}
const SelectField: Component<{
label: string
caption: string
value: string
onInput: (value: string) => void
options: Array<{ value: string; label: string }>
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="min-w-[18rem] max-w-[24rem] w-full">
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</div>
</div>
)
}
export default SpeechSettingsCard

View File

@@ -1,10 +0,0 @@
import type { Component } from "solid-js"
import SpeechSettingsCard from "./speech-settings-card"
export const SpeechSettingsSection: Component = () => {
return (
<div class="settings-section-stack">
<SpeechSettingsCard />
</div>
)
}

View File

@@ -1,34 +0,0 @@
import { Loader2, Volume2 } from "lucide-solid"
import type { JSX } from "solid-js"
interface SpeechActionButtonProps {
class?: string
title: string
isLoading: boolean
isPlaying: boolean
onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
type?: "button" | "submit" | "reset"
}
export default function SpeechActionButton(props: SpeechActionButtonProps) {
return (
<button
type={props.type ?? "button"}
class={props.class}
onClick={props.onClick}
aria-label={props.title}
title={props.title}
>
{props.isLoading ? (
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
) : props.isPlaying ? (
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" />
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" stroke="none" />
</svg>
) : (
<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />
)}
</button>
)
}

View File

@@ -29,7 +29,6 @@ import type {
ToolScrollHelpers, ToolScrollHelpers,
} from "./tool-call/types" } from "./tool-call/types"
import { import {
buildToolSpeechText,
ensureMarkdownContent, ensureMarkdownContent,
getRelativePath, getRelativePath,
getToolIcon, getToolIcon,
@@ -42,8 +41,6 @@ import {
} from "./tool-call/utils" } from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title" import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
const log = getLogger("session") const log = getLogger("session")
@@ -517,7 +514,6 @@ function ToolCallDetails(props: {
}) })
const { renderDiffContent } = createDiffContentRenderer({ const { renderDiffContent } = createDiffContentRenderer({
toolState: props.toolState,
preferences: props.preferences, preferences: props.preferences,
setDiffViewMode: props.setDiffViewMode, setDiffViewMode: props.setDiffViewMode,
isDark: props.isDark, isDark: props.isDark,
@@ -963,21 +959,6 @@ export default function ToolCall(props: ToolCallProps) {
return renderToolTitle() return renderToolTitle()
}) })
const speechText = createMemo(() =>
buildToolSpeechText({
title: headerText(),
state: toolState(),
t,
}),
)
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId ?? "message"}:${toolCallIdentifier()}`,
text: speechText,
})
const canSpeakToolCall = () => speechText().trim().length > 0 && speech.canUseSpeech()
const handleCopyHeader = async (event: MouseEvent) => { const handleCopyHeader = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -1041,16 +1022,6 @@ export default function ToolCall(props: ToolCallProps) {
<Copy class="w-3.5 h-3.5" /> <Copy class="w-3.5 h-3.5" />
</button> </button>
<Show when={canSpeakToolCall()}>
<SpeechActionButton
class="tool-call-header-copy"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<span class="tool-call-header-status" aria-hidden="true"> <span class="tool-call-header-status" aria-hidden="true">
{statusIcon()} {statusIcon()}
</span> </span>

View File

@@ -20,14 +20,6 @@ export function createAnsiContentRenderer(params: {
const runningAnsiRenderer = createAnsiStreamRenderer() const runningAnsiRenderer = createAnsiStreamRenderer()
let runningAnsiSource = "" let runningAnsiSource = ""
const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element)
}
const registerUntracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element, { disableTracking: true })
}
const getMode = () => { const getMode = () => {
const version = params.partVersion?.() const version = params.partVersion?.()
return typeof version === "number" ? String(version) : undefined return typeof version === "number" ? String(version) : undefined
@@ -44,8 +36,6 @@ export function createAnsiContentRenderer(params: {
const cached = cacheHandle.get<AnsiRenderCache>() const cached = cacheHandle.get<AnsiRenderCache>()
const mode = getMode() const mode = getMode()
const isRunningVariant = options.variant === "running" const isRunningVariant = options.variant === "running"
const disableScrollTracking = !isRunningVariant
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
let nextCache: AnsiRenderCache let nextCache: AnsiRenderCache
@@ -97,9 +87,9 @@ export function createAnsiContentRenderer(params: {
} }
return ( return (
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}> <div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} /> <pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} {params.scrollHelpers.renderSentinel()}
</div> </div>
) )
} }

View File

@@ -42,7 +42,7 @@ export function renderDiagnosticsSection(
{entry.displayPath} {entry.displayPath}
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span> <span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
</span> </span>
<span class="tool-call-diagnostic-message" dir="auto">{entry.message}</span> <span class="tool-call-diagnostic-message">{entry.message}</span>
</div> </div>
)} )}
</For> </For>

Some files were not shown because too many files have changed in this diff Show More