Compare commits

..

25 Commits

Author SHA1 Message Date
Shantur Rathore
afc554ef98 fix(i18n): tighten RTL locale follow-up 2026-03-24 21:05:12 +00:00
MusiCode
46150cda5e fix(rtl): auto-detect text direction in reasoning block 2026-03-24 21:04:46 +00:00
MusiCode
0874f78ccf fix(rtl): fix file viewer Monaco direction + remove unrelated files
- Add direction: ltr to .monaco-viewer so the Monaco editor renders
  correctly when the document inherits dir="rtl" from Hebrew locale
- Replace physical margin-left with logical margin-inline-start on
  the refresh button in FilesTab
- Remove manifest.json (unrelated to RTL work, flagged in PR #229)
- Remove docs/rtl-hebrew-deployment.md (no longer needed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
88da377795 chore(release): update manifest for v0.12.5-rtl-he
Auto-generated by release.sh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
3533dabda0 chore(release): update manifest for v0.12.5-rtl-he
Auto-generated by release.sh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
25555ed42c chore(release): update manifest for v0.12.3-rtl-he
Auto-generated by release.sh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
df6c96453f fix(rtl): fix code block direction, selector alignment, and narrow-screen padding
- Add direction: ltr to pre elements so code always displays LTR in RTL UI
- Fix selector secondary text: text-left → text-start, add w-full to
  prevent RTL flex cross-axis drift
- Add dir="ltr" to model path span (opencode/model-id is always LTR)
- Restore padding-inline-start: 2.5rem in narrow-screen media query
  where padding shorthand was overriding it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
db3a786b48 fix(rtl): replace physical left/right CSS properties with logical equivalents
- border-l-[3px] → border-s-[3px] on .message-error-block (both CSS files)
- ml-auto → ms-auto on .message-step-time
- text-left → text-start on buttons and list items across panels and tool-call styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
1e47389df3 fix(rtl): use logical ms-auto instead of ml-auto for connection status positioning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
d7ae575042 fix(manifest): update sha256 for corrected RTL-he zip (329 files, no dist/ prefix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
8346b7b631 chore: update sha256 in manifest for new RTL+Hebrew build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
c441d7d3ce fix(rtl): place textarea nav buttons at inline-start, away from scrollbar
Buttons were originally at right:0.25rem (physical), same side as the scrollbar
in LTR — a pre-existing overlap bug masked by overlay scrollbars on macOS.

Fix: move buttons to inset-inline-start so they are always opposite the scrollbar
in both LTR (buttons left, scrollbar right) and RTL (buttons right, scrollbar left).
Flip padding accordingly: inline-start gets 2.5rem, inline-end gets 0.75rem.

Also add direction:rtl override for RTL to fix dir="auto" defaulting to LTR
on an empty textarea.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
be8fcc98c5 fix(rtl): force scrollbar to right in RTL textarea, buttons at inline-end
Use direction:ltr on the textarea in RTL mode to keep scrollbar on the right
(start side). Nav buttons remain at inset-inline-end (left/end in RTL).
Swap padding so left gets 2.5rem (for buttons) and right 0.75rem (for scrollbar gap).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
658253a3fd fix(rtl): keep textarea nav buttons at physical right to avoid scrollbar overlap
In RTL, browser places textarea scrollbar on the left. Using inset-inline-end
put nav buttons also on the left, causing overlap. Keep physical right/padding-right
so buttons are always away from the scrollbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
0e96662a07 fix(rtl): fix textarea padding direction in RTL
Replace physical pl-3/pr-10 with logical padding-inline-start/end
so the large padding (for nav buttons) is on the correct side in RTL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
eb77c06571 fix(rtl): fix resize direction, path alignment, and i18n gaps
- Invert resize delta in RTL for drawer (useDrawerResize) and split panel (RightPanel)
- Add dir="ltr" to path/code value elements in instance-info panel
- Replace hardcoded English strings with i18n: Hide/Show files, No git changes yet,
  Hide unchanged regions / Show full file, diff toolbar titles, + Create worktree
- Fix sessionList.status.idle: "בסרלה" → "מוכן" in session.ts
- Fix text-align: left → start in message-reasoning-toggle and tool-call-io-toggle
- Fix left: 0 → inset-inline-start: 0 in attachment-chip-preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
a6cb70ed41 fix(i18n): correct Hebrew translation for idle status
Replace nonsensical "בסרלה" with "מוכן" (ready) for instanceTab.status.idle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
13596e8082 feat(ui): add dynamic RTL layout support
- Sync document.documentElement.dir dynamically from I18nProvider
  based on selected locale (RTL for 'he', LTR for all others)
- Flip MUI Drawer anchor props (left/right) reactively via isRTL()
- Convert ~60 physical CSS directional properties to logical equivalents
  (border-inline-start/end, inset-inline-start/end, margin-inline-*, etc.)
- Add [dir="rtl"] overrides for translateX animations (sidebar slide,
  resize handle hit-area extensions, settings nav selection nudge)
- Preserve intentional direction:rtl + text-align:left truncation tricks
  (file path display in .truncate-start and .files-tab-selected-path)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
d9d56d77bc docs: add note about ui-dir path if zip has dist/ prefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
c886344e2f fix: remove dist/ prefix from zip so ui-dir extraction works correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
69cb049a39 fix: correct sha256 in manifest (zip was built from wrong dist path)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
38cdb4ddb1 docs: add unzip as alternative extraction method
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
b11a9e3ec8 docs: add RTL+Hebrew deployment guide
Step-by-step guide covering npm global install (node only, not bun),
UI download and extraction, systemd user service setup, and update flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
268d23e9f6 chore: add UI manifest for RTL+Hebrew release v0.12.3-rtl-he
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
f266577c75 feat(i18n): add full Hebrew (he) locale translation
Translates all ~400 UI strings to Hebrew across 16 message modules.
Registers the 'he' locale in the i18n system and adds עברית to the
language picker. Built on top of the rtl-support branch so RTL layout
applies immediately when Hebrew is selected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
164 changed files with 563 additions and 6768 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",
@@ -31,4 +31,4 @@
"devDependencies": { "devDependencies": {
"baseline-browser-mapping": "^2.9.11" "baseline-browser-mapping": "^2.9.11"
} }
} }

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,67 +132,36 @@ 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 console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
if (this.shouldUsePackagedShellSupervisor(options)) { const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const runtimePath = this.resolveShellNodeCommand() env.ELECTRON_RUN_AS_NODE = "1"
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( const spawnDetails = supportsUserShell()
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`, ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
) : this.buildDirectSpawn(cliEntry, args)
console.info(`[cli] utility supervisor: ${supervisorPath}`)
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
child = utilityProcess.fork(supervisorPath, [supervisorPayload], { const detached = process.platform !== "win32"
env: shellEnv, const child = spawn(spawnDetails.command, spawnDetails.args, {
stdio: "pipe", cwd: process.cwd(),
serviceName: "CodeNomad CLI Supervisor", stdio: ["ignore", "pipe", "pipe"],
}) env,
this.childLaunchMode = "utility" shell: false,
} else { detached,
const cliEntry = this.resolveCliEntry(options) })
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
env.ELECTRON_RUN_AS_NODE = "1" if (!child.pid) {
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
this.childLaunchMode = "spawn"
}
if (this.childLaunchMode === "spawn" && !child.pid) {
console.error("[cli] spawn failed: no pid") console.error("[cli] spawn failed: no pid")
} }
@@ -216,48 +176,23 @@ 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 console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
utilityChild.on("error", (error) => { child.on("exit", (code, signal) => {
const message = this.describeUtilityProcessError(error) const failed = this.status.state !== "ready"
console.error("[cli] utility supervisor failed:", error) const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
this.updateStatus({ state: "error", error: message }) console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.emit("error", new Error(message)) this.updateStatus({ state: failed ? "error" : "stopped", error })
}) if (failed && error) {
this.emit("error", new Error(error))
utilityChild.on("exit", (code) => { }
const failed = this.status.state !== "ready" this.emit("exit", this.status)
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined this.child = 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)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
spawnedChild.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
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}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
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",
@@ -47,4 +46,4 @@
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

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,22 +438,18 @@ 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
@@ -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",

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"

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>

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

@@ -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",
@@ -45,4 +45,4 @@
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0" "vite-plugin-solid": "^2.10.0"
} }
} }

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
}} }}
@@ -185,10 +184,11 @@ const AlertDialog: Component = () => {
> >
{confirmLabel} {confirmLabel}
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </div>
</Dialog> </Dialog.Portal>
</Dialog>
) )
}} }}
</Show> </Show>

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?.()
@@ -259,10 +83,9 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
if (!key) return if (!key) return
if (!diffContainerRef) return if (!diffContainerRef) return
if (lastCapturedKey === key) return if (lastCapturedKey === key) return
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

@@ -443,7 +443,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
aria-label={t("folderSelection.links.githubStars")} aria-label={t("folderSelection.links.githubStars")}
title={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

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")
@@ -104,7 +97,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 +230,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 +252,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 +272,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 +290,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)
} }
@@ -498,7 +420,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
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",
@@ -609,7 +530,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
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",
@@ -700,7 +620,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 +717,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>

View File

@@ -48,103 +48,104 @@ interface SessionSidebarProps {
} }
const SessionSidebar: Component<SessionSidebarProps> = (props) => ( const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}> <div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base"> <div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary"> <span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{props.t("instanceShell.leftPanel.sessionsTitle")} {props.t("instanceShell.leftPanel.sessionsTitle")}
</span> </span>
<div class="flex items-center gap-2 text-primary"> <div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
title={props.t("sessionList.actions.newSession.title")}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
>
<PlusSquare class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("sessionList.filter.ariaLabel")}
aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
>
<Search class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label={props.t("sessionList.actions.newSession.ariaLabel")} aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
title={props.t("sessionList.actions.newSession.title")} onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
> >
<PlusSquare class="w-5 h-5" /> {props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton> </IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label={props.t("sessionList.filter.ariaLabel")} aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
title={props.t("sessionList.filter.ariaLabel")} title={props.t("instanceShell.leftDrawer.toggle.close")}
aria-pressed={props.showSearch()} onClick={props.onCloseLeftDrawer}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
> >
<Search class="w-5 h-5" /> <MenuOpenIcon fontSize="small" />
</IconButton> </IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
>
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
title={props.t("instanceShell.leftDrawer.toggle.close")}
onClick={props.onCloseLeftDrawer}
>
<MenuOpenIcon fontSize="small" />
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show> </Show>
</div> </div>
</div> </div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0"> <div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList <SessionList
instanceId={props.instanceId} instanceId={props.instanceId}
threads={props.threads()} threads={props.threads()}
activeSessionId={props.activeSessionId()} activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession} onSelect={props.onSelectSession}
onNew={() => { onNew={() => {
const result = props.onNewSession() const result = props.onNewSession()
if (result instanceof Promise) { if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error)) void result.catch((error) => log.error("Failed to create session:", error))
} }
}} }}
enableFilterBar={props.showSearch()} enableFilterBar={props.showSearch()}
showHeader={false} showHeader={false}
showFooter={false} showFooter={false}
/> />
<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> )}
</div> </Show>
</div> </div>
) </div>
)
export default SessionSidebar export default SessionSidebar

View File

@@ -24,9 +24,6 @@ import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } f
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 {
@@ -89,7 +86,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 +102,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",
@@ -546,8 +539,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 +559,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 +566,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
@@ -677,7 +578,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedContent(null) setBrowserSelectedContent(null)
setBrowserSelectedLoading(false) setBrowserSelectedLoading(false)
setBrowserSelectedError(null) setBrowserSelectedError(null)
setBrowserSelectedDirty(false)
}) })
createEffect(() => { createEffect(() => {
@@ -730,22 +630,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 +651,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 +670,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())
@@ -948,15 +830,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: string) => void loadBrowserEntries(path)}
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} onOpenFile={(path: string) => 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}

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-inline-start": "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" : ""}`} />
@@ -226,4 +198,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
return <>{renderContent()}</> return <>{renderContent()}</>
} }
export default FilesTab export default FilesTab

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

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

View File

@@ -14,8 +14,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 (
@@ -1386,13 +1384,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(() => { createEffect(() => {
if (!expanded()) return if (!expanded()) return
reasoningText() reasoningText()
@@ -1471,20 +1462,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"

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"

View File

@@ -146,7 +146,6 @@ export default function MessagePart(props: MessagePartProps) {
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>

View File

@@ -1,5 +1,5 @@
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js" import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Volume2, X } from "lucide-solid" import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
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,13 +18,6 @@ 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")) const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
@@ -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
@@ -573,111 +506,42 @@ 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"> <ExpandButton
<Show when={showVoiceInput()}> expandState={expandState}
<button onToggleExpand={handleExpandToggle}
type="button" />
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`} <Show when={hasHistory()}>
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 <button
type="button" type="button"
class="prompt-clear-button" class="prompt-history-button"
onClick={handleClearPrompt} onClick={() =>
disabled={!canClearPrompt()} selectPreviousHistory({
aria-label={t("promptInput.clear.ariaLabel")} force: true,
title={t("promptInput.clear.title")} isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
> >
<X class="h-4 w-4" aria-hidden="true" /> <ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button> </button>
</div> <button
<div class="prompt-nav-column prompt-nav-column-right"> type="button"
<ExpandButton class="prompt-history-button"
expandState={expandState} onClick={() =>
onToggleExpand={handleExpandToggle} selectNextHistory({
/> force: true,
<Show when={hasHistory()}> isPickerOpen: showPicker(),
<button getTextarea: () => textareaRef,
type="button" })
class="prompt-history-button" }
onClick={() => disabled={!canHistoryGoNext()}
selectPreviousHistory({ aria-label={t("promptInput.history.nextAriaLabel")}
force: true, >
isPickerOpen: showPicker(), <ArrowBigDown class="h-5 w-5" aria-hidden="true" />
getTextarea: () => textareaRef, </button>
}) </Show>
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectNextHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoNext()}
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</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" : ""}`}>

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,9 +372,8 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
) )
}} }}
</Show> </Show>
<Show when={displayAddresses().recommended}> <For each={displayAddresses()}>
{(addressAccessor) => { {(address) => {
const address = addressAccessor()
const url = address.remoteUrl const url = address.remoteUrl
const expandedState = () => expandedUrl() === url const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url] const qr = () => qrCodes()[url]
@@ -387,14 +383,13 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
: address.scope === "loopback" : address.scope === "loopback"
? t("remoteAccess.address.scope.loopback") ? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal") : t("remoteAccess.address.scope.internal")
return ( return (
<div class="remote-address"> <div class="remote-address">
<div class="remote-address-main"> <div class="remote-address-main">
<div> <div>
<p class="remote-address-url">{url}</p> <p class="remote-address-url">{url}</p>
<p class="remote-address-meta"> <p class="remote-address-meta">
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip} {address.family.toUpperCase()} {scopeLabel()} {address.ip}
</p> </p>
</div> </div>
<div class="remote-actions"> <div class="remote-actions">
@@ -429,83 +424,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
</div> </div>
) )
}} }}
</Show> </For>
<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) => {
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>
)
}}
</For>
</div>
</Show>
</div>
</Show>
</div> </div>
</Show> </Show>
</Show> </Show>

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

@@ -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,
@@ -39,11 +38,10 @@ export const AppearanceSettingsSection: Component = () => {
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints, toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput, setDiffViewMode,
setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion, setThinkingBlocksExpansion,

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,35 +217,31 @@ 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"> <p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
<div class="settings-password-summary-copy"> <p class="settings-help-text">
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p> {authStatus()!.passwordUserProvided
<p class="settings-help-text"> ? t("remoteAccess.password.status.set")
{authStatus()!.passwordUserProvided : t("remoteAccess.password.status.unset")}
? t("remoteAccess.password.status.set") </p>
: t("remoteAccess.password.status.unset")}
</p>
</div>
<div class="settings-password-actions"> <div class="settings-password-actions">
<button <button
class="settings-pill-button" class="settings-pill-button"
type="button" type="button"
onClick={() => { onClick={() => {
setPasswordFormOpen(!passwordFormOpen()) setPasswordFormOpen(!passwordFormOpen())
setPasswordError(null) setPasswordError(null)
}} }}
> >
{passwordFormOpen() {passwordFormOpen()
? t("remoteAccess.password.actions.cancel") ? t("remoteAccess.password.actions.cancel")
: authStatus()!.passwordUserProvided : authStatus()!.passwordUserProvided
? t("remoteAccess.password.actions.change") ? t("remoteAccess.password.actions.change")
: 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">
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label> <label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
<input <input
@@ -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,9 +341,8 @@ export const RemoteAccessSettingsSection: Component = () => {
}} }}
</Show> </Show>
<Show when={displayAddresses().recommended}> <For each={displayAddresses()}>
{(addressAccessor) => { {(address) => {
const address = addressAccessor()
const url = address.remoteUrl const url = address.remoteUrl
const expandedState = () => expandedUrl() === url const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url] const qr = () => qrCodes()[url]
@@ -391,11 +382,7 @@ export const RemoteAccessSettingsSection: Component = () => {
<div class="remote-qr"> <div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}> <Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => ( {(dataUrl) => (
<img <img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img"
/>
)} )}
</Show> </Show>
</div> </div>
@@ -403,80 +390,7 @@ export const RemoteAccessSettingsSection: Component = () => {
</div> </div>
) )
}} }}
</Show> </For>
<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) => {
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>
)
}}
</For>
</div>
</Show>
</div>
</Show>
</div> </div>
</Show> </Show>
</Show> </Show>

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")
@@ -963,21 +960,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 +1023,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

@@ -1,13 +1,10 @@
import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js" import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk/v2"
import useMediaQuery from "@suid/material/useMediaQuery"
import { AlignJustify, Copy, Split, WrapText } from "lucide-solid"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences" import type { DiffViewMode } from "../../stores/preferences"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath } from "./utils" import { getRelativePath } from "./utils"
import { getCacheEntry } from "../../lib/global-cache" import { getCacheEntry } from "../../lib/global-cache"
import { copyToClipboard } from "../../lib/clipboard"
const LazyToolCallDiffViewer = lazy(() => const LazyToolCallDiffViewer = lazy(() =>
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })), import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
@@ -46,16 +43,6 @@ export function createDiffContentRenderer(params: {
handleScrollRendered: () => void handleScrollRendered: () => void
onContentRendered?: () => void onContentRendered?: () => void
}) { }) {
const compactDiffQuery = useMediaQuery("(max-width: 640px)")
const [mobileModeOverride, setMobileModeOverride] = createSignal<DiffViewMode | undefined>(undefined)
const [wordWrapEnabled, setWordWrapEnabled] = createSignal(true)
createEffect(() => {
if (!compactDiffQuery()) {
setMobileModeOverride(undefined)
}
})
const registerTracked = (element: HTMLDivElement | null) => { const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element) params.scrollHelpers.registerContainer(element)
} }
@@ -71,12 +58,7 @@ export function createDiffContentRenderer(params: {
: params.t("toolCall.diff.label")) : params.t("toolCall.diff.label"))
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff" const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
const preferredMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const effectiveMode = () => {
if (!compactDiffQuery()) return preferredMode()
return mobileModeOverride() || "unified"
}
const shouldWrap = () => wordWrapEnabled()
const themeKey = params.isDark() ? "dark" : "light" const themeKey = params.isDark() ? "dark" : "light"
const state = params.toolState() const state = params.toolState()
const disableScrollTracking = Boolean( const disableScrollTracking = Boolean(
@@ -94,39 +76,16 @@ export function createDiffContentRenderer(params: {
} }
})() })()
const currentMode = createMemo(() => effectiveMode()) let cachedHtml: string | undefined
const currentWrap = createMemo(() => shouldWrap()) const cached = getCacheEntry<RenderCache>(cacheEntryParams)
const cachedHtml = createMemo(() => { const currentMode = diffMode()
const cached = getCacheEntry<RenderCache>(cacheEntryParams) if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
if ( cachedHtml = cached.html
cached
&& cached.text === payload.diffText
&& cached.theme === themeKey
&& cached.mode === currentMode()
&& cached.wrap === currentWrap()
) {
return cached.html
}
return undefined
})
const handleModeChange = (mode: DiffViewMode) => {
if (compactDiffQuery()) {
setMobileModeOverride(mode)
}
params.setDiffViewMode(mode)
} }
const nextViewMode = (): DiffViewMode => (currentMode() === "split" ? "unified" : "split") const handleModeChange = (mode: DiffViewMode) => {
const viewModeTitle = () => params.setDiffViewMode(mode)
nextViewMode() === "split" }
? params.t("toolCall.diff.switchToSplit")
: params.t("toolCall.diff.switchToUnified")
const wordWrapTitle = () =>
wordWrapEnabled()
? params.t("toolCall.diff.disableWordWrap")
: params.t("toolCall.diff.enableWordWrap")
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
const handleDiffRendered = () => { const handleDiffRendered = () => {
if (!disableScrollTracking) { if (!disableScrollTracking) {
@@ -136,54 +95,41 @@ export function createDiffContentRenderer(params: {
} }
return ( return (
<div <div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell" class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
data-diff-mode={currentMode()} ref={registerRef}
ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} >
>
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}> <div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span> <span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
<div class="file-viewer-toolbar"> <div class="tool-call-diff-toggle">
<button <button
type="button" type="button"
class="file-viewer-toolbar-icon-button" class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
onClick={() => void copyToClipboard(payload.diffText)} aria-pressed={diffMode() === "split"}
aria-label={copyPatchTitle()} onClick={() => handleModeChange("split")}
title={copyPatchTitle()}
> >
<Copy class="h-4 w-4" aria-hidden="true" /> {params.t("toolCall.diff.viewMode.split")}
</button> </button>
<button <button
type="button" type="button"
class="file-viewer-toolbar-icon-button" class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
onClick={() => handleModeChange(nextViewMode())} aria-pressed={diffMode() === "unified"}
aria-label={viewModeTitle()} onClick={() => handleModeChange("unified")}
title={viewModeTitle()}
> >
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />} {params.t("toolCall.diff.viewMode.unified")}
</button>
<button
type="button"
class={`file-viewer-toolbar-icon-button${wordWrapEnabled() ? " active" : ""}`}
onClick={() => setWordWrapEnabled((enabled) => !enabled)}
aria-label={wordWrapTitle()}
title={wordWrapTitle()}
>
<WrapText class="h-4 w-4" aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>
{cachedHtml() ? ( {cachedHtml ? (
<CachedDiffMarkup html={cachedHtml()!} onRendered={handleDiffRendered} /> <CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
) : ( ) : (
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}> <Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
<LazyToolCallDiffViewer <LazyToolCallDiffViewer
diffText={payload.diffText} diffText={payload.diffText}
filePath={payload.filePath} filePath={payload.filePath}
theme={themeKey} theme={themeKey}
mode={currentMode()} mode={diffMode()}
wrap={currentWrap()}
cacheEntryParams={cacheEntryParams as any} cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered} onRendered={handleDiffRendered}
/> />

View File

@@ -231,37 +231,3 @@ export function getDefaultToolAction(toolName: string) {
return tGlobal("toolCall.renderer.action.working") return tGlobal("toolCall.renderer.action.working")
} }
} }
export function buildToolSpeechText(options: {
title: string
state?: ToolState
t: (key: string, params?: Record<string, unknown>) => string
}): string {
const sections: string[] = []
if (options.title.trim()) {
sections.push(options.title.trim())
}
const { input, output } = readToolStatePayload(options.state)
const formattedInput = formatUnknown(input)
const formattedOutput = formatUnknown(output)
if (formattedInput?.text?.trim()) {
sections.push(`${options.t("toolCall.io.input")}:\n${formattedInput.text.trim()}`)
}
if (formattedOutput?.text?.trim()) {
sections.push(`${options.t("toolCall.io.output")}:\n${formattedOutput.text.trim()}`)
}
if (options.state?.status === "error" && options.state.error?.trim()) {
sections.push(`${options.t("toolCall.error.label")} ${options.state.error.trim()}`)
}
if (sections.length === 1 && options.state?.status === "pending") {
sections.push(options.t("toolCall.pending.waitingToRun"))
}
return sections.join("\n\n").trim()
}

View File

@@ -7,11 +7,7 @@ import type {
FileSystemCreateFolderResponse, FileSystemCreateFolderResponse,
FileSystemListResponse, FileSystemListResponse,
InstanceData, InstanceData,
SpeechCapabilitiesResponse,
SpeechSynthesisResponse,
SpeechTranscriptionResponse,
ServerMeta, ServerMeta,
VoiceModeStateResponse,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceDescriptor, WorkspaceDescriptor,
WorkspaceFileResponse, WorkspaceFileResponse,
@@ -24,7 +20,6 @@ import type {
WorktreeMap, WorktreeMap,
WorktreeCreateRequest, WorktreeCreateRequest,
} from "../../../server/src/api-types" } from "../../../server/src/api-types"
import { getClientIdentity } from "./client-identity"
import { getLogger } from "./logger" import { getLogger } from "./logger"
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
@@ -125,28 +120,6 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} }
} }
async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers = normalizeHeaders(init?.headers)
if (init?.body !== undefined && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json"
}
const method = (init?.method ?? "GET").toUpperCase()
const startedAt = Date.now()
logHttp(`${method} ${path}`)
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
const message = await response.text()
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`)
}
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt })
return response
}
export const serverApi = { export const serverApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> { fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
@@ -236,16 +209,6 @@ export const serverApi = {
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`, `/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
) )
}, },
writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise<void> {
const params = new URLSearchParams({ path: relativePath })
return request(
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
{
method: "PUT",
body: JSON.stringify({ contents }),
},
)
},
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> { fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`) return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
@@ -272,37 +235,6 @@ export const serverApi = {
body: JSON.stringify({ path }), body: JSON.stringify({ path }),
}) })
}, },
fetchSpeechCapabilities(): Promise<SpeechCapabilitiesResponse> {
return request<SpeechCapabilitiesResponse>("/api/speech/capabilities")
},
transcribeAudio(payload: {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}): Promise<SpeechTranscriptionResponse> {
return request<SpeechTranscriptionResponse>("/api/speech/transcribe", {
method: "POST",
body: JSON.stringify(payload),
})
},
synthesizeSpeech(payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" }): Promise<SpeechSynthesisResponse> {
return request<SpeechSynthesisResponse>("/api/speech/synthesize", {
method: "POST",
body: JSON.stringify(payload),
})
},
synthesizeSpeechStream(
payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" },
signal?: AbortSignal,
): Promise<Response> {
return requestRaw("/api/speech/synthesize/stream", {
method: "POST",
body: JSON.stringify(payload),
signal,
})
},
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> { listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (path && path !== ".") { if (path && path !== ".") {
@@ -350,19 +282,6 @@ export const serverApi = {
{ method: "POST" }, { method: "POST" },
) )
}, },
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
const identity = getClientIdentity()
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
method: "POST",
body: JSON.stringify({ ...identity, enabled }),
})
},
sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }): Promise<void> {
return request<void>("/api/client-connections/pong", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchBackgroundProcessOutput( fetchBackgroundProcessOutput(
instanceId: string, instanceId: string,
processId: string, processId: string,
@@ -387,15 +306,9 @@ export const serverApi = {
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`, `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
) )
}, },
connectEvents( connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
onEvent: (event: WorkspaceEventPayload) => void, sseLogger.info(`Connecting to ${EVENTS_URL}`)
onError?: () => void, const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
onPing?: (payload: { ts?: number }) => void,
) {
const identity = getClientIdentity()
const url = buildClientEventsUrl(identity)
sseLogger.info(`Connecting to ${url}`)
const source = new EventSource(url, { withCredentials: true } as any)
source.onmessage = (event) => { source.onmessage = (event) => {
try { try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload const payload = JSON.parse(event.data) as WorkspaceEventPayload
@@ -408,26 +321,8 @@ export const serverApi = {
sseLogger.warn("EventSource error, closing stream") sseLogger.warn("EventSource error, closing stream")
onError?.() onError?.()
} }
source.addEventListener("codenomad.client.ping", (event: MessageEvent) => {
try {
const payload = event.data ? (JSON.parse(event.data) as { ts?: number }) : {}
onPing?.(payload)
} catch (error) {
sseLogger.error("Failed to parse ping event", error)
}
})
return source return source
}, },
} }
function buildClientEventsUrl(identity: { clientId: string; connectionId: string }): string {
const url = new URL(EVENTS_URL, typeof window !== "undefined" ? window.location.origin : "http://localhost")
url.searchParams.set("clientId", identity.clientId)
url.searchParams.set("connectionId", identity.connectionId)
if (EVENTS_URL.startsWith("http://") || EVENTS_URL.startsWith("https://")) {
return url.toString()
}
return `${url.pathname}${url.search}`
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType } export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -1,58 +0,0 @@
const CLIENT_ID_STORAGE_KEY = "codenomad.client-id"
const CONNECTION_ID_STORAGE_KEY = "codenomad.connection-id"
let cachedClientId: string | null = null
let cachedConnectionId: string | null = null
export function getClientIdentity(): { clientId: string; connectionId: string } {
return {
clientId: getOrCreateClientId(),
connectionId: getOrCreateConnectionId(),
}
}
function getOrCreateClientId(): string {
if (cachedClientId) return cachedClientId
cachedClientId = getOrCreateStoredValue(CLIENT_ID_STORAGE_KEY, window.localStorage)
return cachedClientId
}
function getOrCreateConnectionId(): string {
if (cachedConnectionId) return cachedConnectionId
cachedConnectionId = getOrCreateStoredValue(CONNECTION_ID_STORAGE_KEY, window.sessionStorage)
return cachedConnectionId
}
function getOrCreateStoredValue(key: string, storage: Storage): string {
if (typeof window === "undefined") {
return generateUUID()
}
try {
const existing = storage.getItem(key)
if (existing && existing.trim()) {
return existing.trim()
}
} catch {
return generateUUID()
}
const next = generateUUID()
try {
storage.setItem(key, next)
} catch {
// Ignore storage failures and fall back to the in-memory value.
}
return next
}
function generateUUID(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID()
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
const random = (Math.random() * 16) | 0
const value = char === "x" ? random : (random & 0x3) | 0x8
return value.toString(16)
})
}

View File

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

View File

@@ -34,7 +34,6 @@ export interface UseCommandsOptions {
toggleUsageMetrics: () => void toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void togglePromptSubmitOnEnter: () => void
toggleShowPromptVoiceInput: () => void
setDiffViewMode: (mode: "split" | "unified") => void setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -436,7 +435,6 @@ export function useCommands(options: UseCommandsOptions) {
toggleUsageMetrics: options.toggleUsageMetrics, toggleUsageMetrics: options.toggleUsageMetrics,
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter, togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput: options.toggleShowPromptVoiceInput,
setDiffViewMode: options.setDiffViewMode, setDiffViewMode: options.setDiffViewMode,
setToolOutputExpansion: options.setToolOutputExpansion, setToolOutputExpansion: options.setToolOutputExpansion,
setDiagnosticsExpansion: options.setDiagnosticsExpansion, setDiagnosticsExpansion: options.setDiagnosticsExpansion,

View File

@@ -1,416 +0,0 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { showAlertDialog } from "../../stores/alerts"
import { serverApi } from "../api-client"
import { useI18n } from "../i18n"
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { formatToMimeType, getSpeechPlaybackSupport } from "../speech-playback-support"
type SpeechPlaybackState = "idle" | "loading" | "playing"
interface UseSpeechOptions {
id: Accessor<string>
text: Accessor<string>
settingsOverride?: Accessor<Partial<Pick<SpeechSettings, "playbackMode" | "ttsFormat">>>
}
interface ActivePlaybackEntry {
ownerId: string
stop: () => void
}
const stateResetters = new Map<string, () => void>()
let activePlayback: ActivePlaybackEntry | null = null
function resetOwnerState(ownerId: string) {
stateResetters.get(ownerId)?.()
}
function stopActivePlayback(ownerId?: string) {
if (!activePlayback) return
if (ownerId && activePlayback.ownerId !== ownerId) return
const current = activePlayback
activePlayback = null
current.stop()
}
function setActivePlayback(ownerId: string, stop: () => void) {
if (activePlayback?.ownerId === ownerId) {
activePlayback = { ownerId, stop }
return
}
stopActivePlayback()
activePlayback = { ownerId, stop }
}
export function useSpeech(options: UseSpeechOptions) {
const { t } = useI18n()
const { serverSettings } = useConfig()
const [state, setState] = createSignal<SpeechPlaybackState>("idle")
let requestVersion = 0
let audio: HTMLAudioElement | null = null
let objectUrl: string | null = null
let mediaSource: MediaSource | null = null
let abortController: AbortController | null = null
createEffect(() => {
void loadSpeechCapabilities()
})
const cleanupAudio = () => {
if (abortController) {
abortController.abort()
abortController = null
}
if (audio) {
audio.pause()
audio.currentTime = 0
audio.src = ""
audio.load()
audio = null
}
mediaSource = null
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = null
}
}
const resetState = () => {
requestVersion += 1
cleanupAudio()
setState("idle")
}
stateResetters.set(options.id(), resetState)
onCleanup(() => {
stateResetters.delete(options.id())
stopActivePlayback(options.id())
resetState()
})
const isSupported = () => typeof window !== "undefined" && typeof window.Audio !== "undefined"
const resolvedSettings = () => ({
...serverSettings().speech,
...(options.settingsOverride?.() ?? {}),
})
const canUseSpeech = () => {
const capabilities = speechCapabilities()
if (!isSupported() || !capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
return false
}
return getSpeechPlaybackSupport({
playbackMode: resolvedSettings().playbackMode,
ttsFormat: resolvedSettings().ttsFormat,
capabilities,
}).available
}
const stop = () => {
if (activePlayback?.ownerId === options.id()) {
activePlayback = null
}
resetState()
}
const start = async () => {
const ownerId = options.id()
const text = options.text().trim()
if (!text || state() === "loading" || state() === "playing") return
if (!isSupported()) {
showAlertDialog(t("messageItem.actions.speak.error.unsupported"), {
title: t("messageItem.actions.speak.error.title"),
variant: "error",
})
return
}
const capabilities = (await loadSpeechCapabilities()) ?? speechCapabilities()
if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
showAlertDialog(t("messageItem.actions.speak.error.unavailable"), {
title: t("messageItem.actions.speak.error.title"),
variant: "error",
})
return
}
const support = getSpeechPlaybackSupport({
playbackMode: resolvedSettings().playbackMode,
ttsFormat: resolvedSettings().ttsFormat,
capabilities,
})
if (!support.available) {
const detailKey =
support.reason === "provider-streaming-unavailable"
? "settings.speech.compatibility.streamingUnavailable"
: support.reason === "browser-streaming-unavailable"
? "settings.speech.compatibility.browserStreamingUnavailable"
: "messageItem.actions.speak.error.unsupported"
showAlertDialog(t("messageItem.actions.speak.error.unavailable"), {
title: t("messageItem.actions.speak.error.title"),
detail: t(detailKey),
variant: "error",
})
return
}
requestVersion += 1
const currentRequest = requestVersion
stopActivePlayback()
cleanupAudio()
setState("loading")
const settings = resolvedSettings()
const format = settings.ttsFormat
try {
if (settings.playbackMode === "streaming") {
await startStreamingPlayback(ownerId, currentRequest, text, format)
} else {
await startBufferedPlayback(ownerId, currentRequest, text, format)
}
} catch (error) {
if (currentRequest !== requestVersion) {
return
}
resetState()
showAlertDialog(t("messageItem.actions.speak.error.generate"), {
title: t("messageItem.actions.speak.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
}
async function startBufferedPlayback(
ownerId: string,
currentRequest: number,
text: string,
format: "mp3" | "wav" | "opus" | "aac",
) {
const response = await serverApi.synthesizeSpeech({ text, format })
if (currentRequest !== requestVersion) {
return
}
const nextUrl = createObjectUrlFromBase64(response.audioBase64, response.mimeType)
const nextAudio = new Audio(nextUrl)
objectUrl = nextUrl
audio = nextAudio
attachPlaybackLifecycle(ownerId, nextAudio)
setActivePlayback(ownerId, () => {
cleanupAudio()
setState("idle")
})
setState("playing")
await nextAudio.play()
}
async function startStreamingPlayback(
ownerId: string,
currentRequest: number,
text: string,
format: "mp3" | "wav" | "opus" | "aac",
) {
if (typeof MediaSource === "undefined") {
throw new Error("MediaSource is not available in this browser.")
}
const controller = new AbortController()
abortController = controller
const response = await serverApi.synthesizeSpeechStream({ text, format }, controller.signal)
const mimeType = response.headers.get("content-type") || formatToMimeType(format)
if (!MediaSource.isTypeSupported(mimeType)) {
throw new Error(`Streaming playback is not supported for ${mimeType}.`)
}
const stream = response.body
if (!stream) {
throw new Error("Speech stream did not include a response body.")
}
const nextMediaSource = new MediaSource()
const nextObjectUrl = URL.createObjectURL(nextMediaSource)
const nextAudio = new Audio(nextObjectUrl)
mediaSource = nextMediaSource
objectUrl = nextObjectUrl
audio = nextAudio
attachPlaybackLifecycle(ownerId, nextAudio)
setActivePlayback(ownerId, () => {
cleanupAudio()
setState("idle")
})
await new Promise<void>((resolve, reject) => {
const handleSourceOpen = () => {
nextMediaSource.removeEventListener("sourceopen", handleSourceOpen)
void streamToMediaSource({
mediaSource: nextMediaSource,
stream,
mimeType,
audioElement: nextAudio,
onPlayable: async () => {
if (currentRequest !== requestVersion) return
if (state() !== "playing") {
setState("playing")
}
try {
await nextAudio.play()
} catch (error) {
reject(error)
}
},
onComplete: resolve,
onError: reject,
})
}
nextMediaSource.addEventListener("sourceopen", handleSourceOpen, { once: true })
nextAudio.addEventListener(
"error",
() => reject(new Error("Unable to play streamed speech.")),
{ once: true },
)
})
}
const toggle = async () => {
if (state() === "idle") {
await start()
return
}
stop()
}
return {
state,
canUseSpeech,
isLoading: () => state() === "loading",
isPlaying: () => state() === "playing",
toggle,
stop,
buttonTitle: () => {
if (state() === "loading") return t("messageItem.actions.generatingSpeech")
if (state() === "playing") return t("messageItem.actions.stopSpeech")
return t("messageItem.actions.speak")
},
}
}
function attachPlaybackLifecycle(ownerId: string, audio: HTMLAudioElement) {
const finish = () => {
if (activePlayback?.ownerId === ownerId) {
activePlayback = null
}
resetOwnerState(ownerId)
}
audio.addEventListener("ended", finish, { once: true })
audio.addEventListener("error", finish, { once: true })
}
async function streamToMediaSource(options: {
mediaSource: MediaSource
stream: ReadableStream<Uint8Array>
mimeType: string
audioElement: HTMLAudioElement
onPlayable: () => Promise<void>
onComplete: () => void
onError: (error: unknown) => void
}) {
try {
const sourceBuffer = options.mediaSource.addSourceBuffer(options.mimeType)
const reader = options.stream.getReader()
let startedPlayback = false
let queue: Uint8Array[] = []
let processing = false
const flushQueue = async () => {
if (processing || sourceBuffer.updating || queue.length === 0) return
processing = true
const chunk = queue.shift()!
await appendChunk(sourceBuffer, chunk)
if (!startedPlayback) {
startedPlayback = true
await options.onPlayable()
}
processing = false
await flushQueue()
}
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value && value.byteLength > 0) {
queue.push(value)
await flushQueue()
}
}
while (queue.length > 0 || sourceBuffer.updating) {
if (queue.length > 0) {
await flushQueue()
} else {
await waitForUpdateEnd(sourceBuffer)
}
}
if (options.mediaSource.readyState === "open") {
options.mediaSource.endOfStream()
}
options.onComplete()
} catch (error) {
options.onError(error)
}
}
function appendChunk(sourceBuffer: SourceBuffer, chunk: Uint8Array): Promise<void> {
return new Promise((resolve, reject) => {
const handleUpdateEnd = () => {
cleanup()
resolve()
}
const handleError = () => {
cleanup()
reject(new Error("Failed to append audio stream chunk."))
}
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", handleUpdateEnd)
sourceBuffer.removeEventListener("error", handleError)
}
sourceBuffer.addEventListener("updateend", handleUpdateEnd, { once: true })
sourceBuffer.addEventListener("error", handleError, { once: true })
sourceBuffer.appendBuffer(new Uint8Array(chunk).buffer)
})
}
function waitForUpdateEnd(sourceBuffer: SourceBuffer): Promise<void> {
return new Promise((resolve) => {
sourceBuffer.addEventListener("updateend", () => resolve(), { once: true })
})
}
function createObjectUrlFromBase64(audioBase64: string, mimeType: string): string {
const binary = atob(audioBase64)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
}

View File

@@ -26,6 +26,7 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Sessions", "instanceShell.leftPanel.sessionsTitle": "Sessions",
"instanceShell.leftPanel.instanceInfo": "Instance Info", "instanceShell.leftPanel.instanceInfo": "Instance Info",
"instanceShell.leftDrawer.pin": "Pin left drawer", "instanceShell.leftDrawer.pin": "Pin left drawer",
"instanceShell.leftDrawer.unpin": "Unpin left drawer", "instanceShell.leftDrawer.unpin": "Unpin left drawer",
"instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned", "instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned",
@@ -94,20 +95,6 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "Status", "instanceShell.rightPanel.tabs.status": "Status",
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs", "instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
"instanceShell.rightPanel.actions.refresh": "Refresh", "instanceShell.rightPanel.actions.refresh": "Refresh",
"instanceShell.rightPanel.actions.save": "Save (Ctrl+S)",
"instanceShell.rightPanel.actions.saveConfirm.message": "Do you want to save changes to \"{path}\" before switching?",
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Save",
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Discard Changes",
"instanceShell.rightPanel.actions.conflict.message": "File was modified by the agent. Overwrite agent's changes?",
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Overwrite",
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancel",
"instanceShell.rightPanel.actions.refreshDirty.message": "File has unsaved changes. Refresh will discard your edits. Continue?",
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Refresh",
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
"instanceShell.rightPanel.toast.saveError": "Failed to save file",
"instanceShell.rightPanel.sections.yoloMode": "Yolo Mode",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Automatically approves permission requests for the current session. Use it only when you trust the tools being run.",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes", "instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
@@ -151,12 +138,6 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Select a session to view plan.", "instanceShell.plan.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.", "instanceShell.plan.empty": "Nothing planned yet.",
"instanceShell.yoloMode.noSessionSelected": "Select a session to configure Yolo mode.",
"instanceShell.yoloMode.title": "Yolo mode",
"instanceShell.yoloMode.description": "Automatically approve permission requests for this session. Disabled by default.",
"instanceShell.yoloMode.badge": "Yolo mode",
"instanceShell.yoloMode.badgeAriaLabel": "Yolo mode enabled",
"instanceShell.backgroundProcesses.empty": "No background processes.", "instanceShell.backgroundProcesses.empty": "No background processes.",
"instanceShell.backgroundProcesses.status": "Status: {status}", "instanceShell.backgroundProcesses.status": "Status: {status}",
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",

View File

@@ -75,13 +75,6 @@ export const messagingMessages = {
"messageItem.actions.copy": "Copy", "messageItem.actions.copy": "Copy",
"messageItem.actions.copyTitle": "Copy message", "messageItem.actions.copyTitle": "Copy message",
"messageItem.actions.copied": "Copied!", "messageItem.actions.copied": "Copied!",
"messageItem.actions.speak": "Speak message",
"messageItem.actions.generatingSpeech": "Generating speech",
"messageItem.actions.stopSpeech": "Stop playback",
"messageItem.actions.speak.error.title": "Speech playback failed",
"messageItem.actions.speak.error.unsupported": "Speech playback is not supported in this browser.",
"messageItem.actions.speak.error.unavailable": "Speech playback is unavailable until speech settings are configured.",
"messageItem.actions.speak.error.generate": "Unable to generate speech for this message.",
"messageItem.actions.deleteMessage": "Delete message (doesn't undo changes)", "messageItem.actions.deleteMessage": "Delete message (doesn't undo changes)",
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)", "messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
"messageItem.actions.deletingMessage": "Deleting...", "messageItem.actions.deletingMessage": "Deleting...",
@@ -142,21 +135,7 @@ export const messagingMessages = {
"promptInput.overlay.againToAbort": "again to abort session", "promptInput.overlay.againToAbort": "again to abort session",
"promptInput.stopSession.ariaLabel": "Stop session", "promptInput.stopSession.ariaLabel": "Stop session",
"promptInput.stopSession.title": "Stop session", "promptInput.stopSession.title": "Stop session",
"promptInput.clear.ariaLabel": "Clear prompt text",
"promptInput.clear.title": "Clear prompt text",
"promptInput.send.ariaLabel": "Send message", "promptInput.send.ariaLabel": "Send message",
"promptInput.send.errorFallback": "Failed to send message", "promptInput.send.errorFallback": "Failed to send message",
"promptInput.send.errorTitle": "Send failed", "promptInput.send.errorTitle": "Send failed",
"promptInput.conversationMode.enable.title": "Enable conversation mode",
"promptInput.conversationMode.disable.title": "Disable conversation mode",
"promptInput.conversationMode.error.title": "Conversation playback failed",
"promptInput.conversationMode.error.message": "Unable to continue speaking assistant replies.",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.permissionDenied": "Microphone access was denied by macOS.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const } as const

View File

@@ -41,8 +41,6 @@ export const remoteAccessMessages = {
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.", "remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
"remoteAccess.addresses.loading": "Loading addresses…", "remoteAccess.addresses.loading": "Loading addresses…",
"remoteAccess.addresses.none": "No addresses available yet.", "remoteAccess.addresses.none": "No addresses available yet.",
"remoteAccess.addresses.actions.showOther": "Show {count} other addresses",
"remoteAccess.addresses.actions.hideOther": "Hide other addresses",
"remoteAccess.address.scope.network": "Network", "remoteAccess.address.scope.network": "Network",
"remoteAccess.address.scope.loopback": "Loopback", "remoteAccess.address.scope.loopback": "Loopback",
"remoteAccess.address.scope.internal": "Internal", "remoteAccess.address.scope.internal": "Internal",

View File

@@ -15,10 +15,6 @@ export const sessionMessages = {
"sessionList.status.working": "Working", "sessionList.status.working": "Working",
"sessionList.status.compacting": "Compacting", "sessionList.status.compacting": "Compacting",
"sessionList.status.idle": "Idle", "sessionList.status.idle": "Idle",
"sessionList.status.retrying": "Retrying",
"sessionList.status.retryingIn": "Retrying in {seconds}s",
"sessionList.status.retryTooltip": "{message} (Attempt {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Attempt {attempt})",
"sessionList.status.needsPermission": "Needs Permission", "sessionList.status.needsPermission": "Needs Permission",
"sessionList.status.needsInput": "Needs Input", "sessionList.status.needsInput": "Needs Input",
"sessionList.expand.collapseAriaLabel": "Collapse session", "sessionList.expand.collapseAriaLabel": "Collapse session",
@@ -29,15 +25,12 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "New session", "sessionList.actions.newSession.title": "New session",
"sessionList.actions.copyId.ariaLabel": "Copy session ID", "sessionList.actions.copyId.ariaLabel": "Copy session ID",
"sessionList.actions.copyId.title": "Copy session ID", "sessionList.actions.copyId.title": "Copy session ID",
"sessionList.actions.reload.ariaLabel": "Reload session",
"sessionList.actions.reload.title": "Reload session",
"sessionList.actions.rename.ariaLabel": "Rename session", "sessionList.actions.rename.ariaLabel": "Rename session",
"sessionList.actions.rename.title": "Rename session", "sessionList.actions.rename.title": "Rename session",
"sessionList.actions.delete.ariaLabel": "Delete session", "sessionList.actions.delete.ariaLabel": "Delete session",
"sessionList.actions.delete.title": "Delete session", "sessionList.actions.delete.title": "Delete session",
"sessionList.copyId.success": "Session ID copied", "sessionList.copyId.success": "Session ID copied",
"sessionList.copyId.error": "Unable to copy session ID", "sessionList.copyId.error": "Unable to copy session ID",
"sessionList.reload.error": "Unable to reload session",
"sessionList.delete.error": "Unable to delete session", "sessionList.delete.error": "Unable to delete session",
"sessionList.delete.title": "Delete session", "sessionList.delete.title": "Delete session",
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.", "sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",

View File

@@ -65,7 +65,6 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance", "settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications", "settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access", "settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode", "settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device", "settings.scope.device": "This device",
"settings.scope.server": "Server setting", "settings.scope.server": "Server setting",
@@ -138,52 +137,6 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.", "settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions", "settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.", "settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enter to submit", "settings.behavior.promptSubmit.title": "Enter to submit",
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.", "settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.apiKey.placeholder": "Enter a new API key",
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
"settings.speech.apiKey.clearAction": "Clear saved key",
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.playbackMode.title": "Playback mode",
"settings.speech.playbackMode.subtitle": "Choose whether TTS starts playing as audio streams in or after the full file is generated.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.ttsFormat.title": "Output format",
"settings.speech.ttsFormat.subtitle": "Choose the audio format for synthesized speech. Streaming support depends on your provider and browser.",
"settings.speech.help": "Prompt voice input appears when speech transcription is configured and supported. Message playback uses the TTS mode and format selected here.",
"settings.speech.compatibility.streamingUnavailable": "Your current speech provider configuration does not advertise streaming TTS. Switch playback mode to buffered if you want playback to work now.",
"settings.speech.compatibility.browserStreamingUnavailable": "Your current browser cannot stream the selected TTS format. Choose buffered playback or switch to a different format.",
"settings.speech.compatibility.runtimeNote": "All formats stay selectable in streaming mode. Some browser and provider combinations may still fail at playback time.",
"settings.speech.testPlayback.action": "Test playback",
"settings.speech.testPlayback.generating": "Generating sample",
"settings.speech.testPlayback.stop": "Stop sample",
"settings.speech.testPlayback.sample": "Thank you for using CodeNomad, your speech settings are working fine.",
"settings.speech.testPlayback.note": "The test uses your current playback mode and format immediately. Save API key, base URL, model, or voice changes first if you want those reflected too.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const } as const

View File

@@ -18,11 +18,6 @@ export const toolCallMessages = {
"toolCall.diff.viewMode.ariaLabel": "Diff view mode", "toolCall.diff.viewMode.ariaLabel": "Diff view mode",
"toolCall.diff.viewMode.split": "Split", "toolCall.diff.viewMode.split": "Split",
"toolCall.diff.viewMode.unified": "Unified", "toolCall.diff.viewMode.unified": "Unified",
"toolCall.diff.switchToSplit": "Switch to split view",
"toolCall.diff.switchToUnified": "Switch to unified view",
"toolCall.diff.enableWordWrap": "Enable word wrap",
"toolCall.diff.disableWordWrap": "Disable word wrap",
"toolCall.diff.copyPatch": "Copy patch",
"toolCall.diagnostics.title": "Diagnostics", "toolCall.diagnostics.title": "Diagnostics",
"toolCall.diagnostics.ariaLabel": "Diagnostics", "toolCall.diagnostics.ariaLabel": "Diagnostics",

View File

@@ -26,6 +26,7 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Sesiones", "instanceShell.leftPanel.sessionsTitle": "Sesiones",
"instanceShell.leftPanel.instanceInfo": "Info de la instancia", "instanceShell.leftPanel.instanceInfo": "Info de la instancia",
"instanceShell.leftDrawer.pin": "Fijar panel izquierdo", "instanceShell.leftDrawer.pin": "Fijar panel izquierdo",
"instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo", "instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo",
"instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado", "instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado",
@@ -93,21 +94,6 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
"instanceShell.rightPanel.actions.refresh": "Actualizar",
"instanceShell.rightPanel.actions.save": "Guardar (Ctrl+S)",
"instanceShell.rightPanel.actions.saveConfirm.message": "¿Deseas guardar los cambios en \"{path}\" antes de cambiar?",
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Guardar",
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Descartar cambios",
"instanceShell.rightPanel.actions.conflict.message": "El archivo fue modificado por el agente. ¿Sobrescribir los cambios del agente?",
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Sobrescribir",
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancelar",
"instanceShell.rightPanel.actions.refreshDirty.message": "El archivo tiene cambios sin guardar. Actualizar discardará tus ediciones. ¿Continuar?",
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualizar",
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
"instanceShell.rightPanel.sections.yoloMode": "Modo yolo",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Aprueba automaticamente las solicitudes de permiso de la sesion actual. Usalo solo si confias en las herramientas que se estan ejecutando.",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión", "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
@@ -141,12 +127,6 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.", "instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
"instanceShell.plan.empty": "Aún no hay nada planificado.", "instanceShell.plan.empty": "Aún no hay nada planificado.",
"instanceShell.yoloMode.noSessionSelected": "Selecciona una sesion para configurar el modo yolo.",
"instanceShell.yoloMode.title": "Modo yolo",
"instanceShell.yoloMode.description": "Aprueba automaticamente las solicitudes de permiso de esta sesion. Esta desactivado por defecto.",
"instanceShell.yoloMode.badge": "Modo yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Modo yolo activado",
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.", "instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
"instanceShell.backgroundProcesses.status": "Estado: {status}", "instanceShell.backgroundProcesses.status": "Estado: {status}",
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB", "instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",

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