Compare commits
62 Commits
v0.13.1-de
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7996e514c4 | ||
|
|
141be2cde0 | ||
|
|
259d457209 | ||
|
|
d0a0325d7e | ||
|
|
19a4c3df16 | ||
|
|
10506920ac | ||
|
|
92c029d744 | ||
|
|
6eb3246d37 | ||
|
|
5c90de84de | ||
|
|
455a59f693 | ||
|
|
a89da02d6b | ||
|
|
69d9e95bee | ||
|
|
893d5f9296 | ||
|
|
e82e529a8f | ||
|
|
4f236ce36f | ||
|
|
2ffeb45a9c | ||
|
|
df16b64a95 | ||
|
|
f3c54df283 | ||
|
|
5658a9f62d | ||
|
|
9d6a5bcdc0 | ||
|
|
514b187b00 | ||
|
|
240acb7729 | ||
|
|
278b563c1a | ||
|
|
0af79002ed | ||
|
|
f3981a1cce | ||
|
|
031e8d5717 | ||
|
|
995fb3b6a3 | ||
|
|
aeb0ff11b3 | ||
|
|
b61cfbd9f9 | ||
|
|
481dd1a88a | ||
|
|
3f6cdd36f3 | ||
|
|
fe932c8307 | ||
|
|
64ac885157 | ||
|
|
27bccb8d6b | ||
|
|
153065d025 | ||
|
|
2abda0e6b4 | ||
|
|
800133361d | ||
|
|
034cb5dea9 | ||
|
|
d7ab84f245 | ||
|
|
201988b97c | ||
|
|
6a6fcff2c8 | ||
|
|
f29f197b9a | ||
|
|
dbde403b3e | ||
|
|
230c981cc2 | ||
|
|
34978c87fb | ||
|
|
3e6d0a402c | ||
|
|
e81c5f6443 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
5
.github/workflows/comment-pr-artifacts.yml
vendored
5
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
@@ -19,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
fi
|
||||
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
7
.github/workflows/pr-build.yml
vendored
7
.github/workflows/pr-build.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
@@ -23,7 +24,7 @@ jobs:
|
||||
allowed: ${{ steps.auth.outputs.allowed }}
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
steps:
|
||||
- name: Check PR authorization
|
||||
@@ -37,11 +38,11 @@ jobs:
|
||||
fi
|
||||
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
|
||||
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
|
||||
fi
|
||||
|
||||
build:
|
||||
|
||||
7
.github/workflows/restrict-non-dev-prs.yml
vendored
7
.github/workflows/restrict-non-dev-prs.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
|
||||
@@ -17,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
steps:
|
||||
@@ -27,7 +28,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -50,5 +51,5 @@ jobs:
|
||||
- name: Fail unauthorized PR
|
||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||
run: |
|
||||
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||
exit 1
|
||||
|
||||
171
README.md
171
README.md
@@ -1,128 +1,127 @@
|
||||
# CodeNomad
|
||||
|
||||
## A fast, multi-instance workspace for running OpenCode sessions.
|
||||
## The AI Coding Cockpit for OpenCode
|
||||
|
||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
|
||||
CodeNomad transforms OpenCode from a terminal tool into a **premium desktop workspace** — built for developers who live inside AI coding sessions for hours and need control, speed, and clarity.
|
||||
|
||||
> OpenCode gives you the engine. CodeNomad gives you the cockpit.
|
||||
|
||||

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

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

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

|
||||
_Browser support via CodeNomad Server._
|
||||
|
||||
</details>
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
Choose the way that fits your workflow:
|
||||
### 🖥️ Desktop App
|
||||
|
||||
### 🖥️ Desktop App (Recommended)
|
||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
||||
Available as both Electron and Tauri builds — choose based on your preference.
|
||||
|
||||
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Run**: Install and launch like any other app.
|
||||
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
|
||||
|
||||
### 🦀 Tauri App (Experimental)
|
||||
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
||||
|
||||
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
||||
| Platform | Formats |
|
||||
|----------|---------|
|
||||
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
|
||||
| Windows | NSIS Installer, ZIP (x64, ARM64) |
|
||||
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
|
||||
|
||||
### 💻 CodeNomad Server
|
||||
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
|
||||
|
||||
Run as a local server and access via browser. Perfect for remote development.
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||
- [packages/server/README.md](packages/server/README.md)
|
||||
|
||||
To see all available options:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
|
||||
|
||||
### 🧪 Dev Releases
|
||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||
|
||||
Bleeding-edge builds from the `dev` branch:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||
- **Node.js 18+** — for server mode or building from source
|
||||
|
||||
## Troubleshooting
|
||||
---
|
||||
|
||||
### macOS says the app is damaged
|
||||
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
xattr -l /Applications/CodeNomad.app
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
||||
|
||||
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
|
||||
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
|
||||
|
||||
Try running with one of these environment variables:
|
||||
|
||||
```bash
|
||||
# Most reliable workaround (can reduce rendering performance)
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
|
||||
# Alternative for some Wayland setups
|
||||
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
|
||||
```
|
||||
|
||||
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
|
||||
```
|
||||
|
||||
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
|
||||
|
||||
## Architecture & Development
|
||||
|
||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||
CodeNomad is a monorepo built with:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
||||
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
||||
| **[packages/server](packages/server/README.md)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech |
|
||||
| **[packages/ui](packages/ui/README.md)** | SolidJS frontend — reactive, fast, beautiful |
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | Desktop shell — process management, IPC, native dialogs |
|
||||
| **[packages/tauri-app](packages/tauri-app)** | Tauri desktop shell (experimental) |
|
||||
|
||||
### Quick Build
|
||||
To build the Desktop App from source:
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repo.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||
```bash
|
||||
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
|
||||
cd CodeNomad
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS: "CodeNomad.app is damaged and can't be opened"</strong></summary>
|
||||
|
||||
Gatekeeper flag due to missing notarization. Clear the quarantine attribute:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
On Intel Macs, also check **System Settings → Privacy & Security** on first launch.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux (Wayland + NVIDIA): Tauri App closes immediately</strong></summary>
|
||||
|
||||
WebKitGTK DMA-BUF/GBM issue. Run with:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
```
|
||||
|
||||
See full workaround in the original README.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
|
||||
---
|
||||
|
||||
**Built with ♥ by [Neural Nomads](https://github.com/NeuralNomadsAI)** · [MIT License](LICENSE)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 845 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 835 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 966 KiB After Width: | Height: | Size: 1.1 MiB |
81
package-lock.json
generated
81
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -64,7 +64,6 @@
|
||||
"version": "7.28.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -3381,7 +3380,6 @@
|
||||
"version": "7.20.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
@@ -3483,7 +3481,6 @@
|
||||
"version": "22.19.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3558,7 +3555,6 @@
|
||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -3641,7 +3637,6 @@
|
||||
"version": "6.12.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -3844,6 +3839,7 @@
|
||||
"version": "5.3.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"async": "^3.2.4",
|
||||
@@ -3861,6 +3857,7 @@
|
||||
"version": "2.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.4",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -3881,6 +3878,7 @@
|
||||
"version": "2.3.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -3894,12 +3892,14 @@
|
||||
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -4213,6 +4213,7 @@
|
||||
"version": "4.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
@@ -4276,7 +4277,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4767,6 +4767,7 @@
|
||||
"version": "4.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"crc32-stream": "^4.0.2",
|
||||
@@ -4896,6 +4897,7 @@
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
@@ -4907,6 +4909,7 @@
|
||||
"version": "4.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
@@ -5272,7 +5275,6 @@
|
||||
"version": "24.13.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"builder-util": "24.13.1",
|
||||
@@ -5439,6 +5441,7 @@
|
||||
"version": "24.13.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"archiver": "^5.3.1",
|
||||
@@ -5450,6 +5453,7 @@
|
||||
"version": "10.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -5463,6 +5467,7 @@
|
||||
"version": "6.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -5474,6 +5479,7 @@
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -6191,7 +6197,8 @@
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
@@ -7408,7 +7415,8 @@
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/isbinaryfile": {
|
||||
"version": "5.0.6",
|
||||
@@ -7458,7 +7466,6 @@
|
||||
"version": "1.21.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -7590,6 +7597,7 @@
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
@@ -7601,6 +7609,7 @@
|
||||
"version": "2.3.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -7614,12 +7623,14 @@
|
||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -7684,22 +7695,26 @@
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.difference": {
|
||||
"version": "4.5.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.flatten": {
|
||||
"version": "4.4.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.sortby": {
|
||||
"version": "4.7.0",
|
||||
@@ -7711,7 +7726,8 @@
|
||||
"node_modules/lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
@@ -8515,7 +8531,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8663,7 +8678,8 @@
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "3.0.0",
|
||||
@@ -8912,6 +8928,7 @@
|
||||
"version": "3.6.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -8925,6 +8942,7 @@
|
||||
"version": "1.1.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
@@ -9227,7 +9245,6 @@
|
||||
"version": "4.52.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -9451,7 +9468,6 @@
|
||||
"node_modules/seroval": {
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -9775,7 +9791,6 @@
|
||||
"node_modules/solid-js": {
|
||||
"version": "1.9.10",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.3.0",
|
||||
@@ -9916,6 +9931,7 @@
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -10249,6 +10265,7 @@
|
||||
"version": "2.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
@@ -10441,7 +10458,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10691,7 +10707,6 @@
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11039,7 +11054,6 @@
|
||||
"version": "5.4.21",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -11524,7 +11538,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -11719,7 +11732,6 @@
|
||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
@@ -12008,6 +12020,7 @@
|
||||
"version": "4.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^3.0.4",
|
||||
"compress-commons": "^4.1.2",
|
||||
@@ -12021,6 +12034,7 @@
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.2.3",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -12040,7 +12054,6 @@
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -12055,7 +12068,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12092,7 +12105,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12134,7 +12147,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12142,7 +12155,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -22,7 +22,7 @@
|
||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version && npm run sync:version --workspace @codenomad/tauri-app"
|
||||
"bumpVersion": "node ./scripts/bump-version.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.13.1",
|
||||
"minServerVersion": "0.13.3",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,23 @@ export interface Env {
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname === "/version.json") {
|
||||
const response = await env.ASSETS.fetch(request)
|
||||
|
||||
const newHeaders = new Headers(response.headers)
|
||||
newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
|
||||
newHeaders.set("Pragma", "no-cache")
|
||||
newHeaders.set("Expires", "0")
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
return env.ASSETS.fetch(request)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"remote:openWindow",
|
||||
async (
|
||||
_event,
|
||||
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
|
||||
): Promise<{ ok: boolean }> => {
|
||||
const opener = (mainWindow as BrowserWindow & {
|
||||
__codenomadOpenRemoteWindow?: (payload: {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
skipTlsVerify: boolean
|
||||
}) => Promise<void>
|
||||
}).__codenomadOpenRemoteWindow
|
||||
if (!opener) {
|
||||
throw new Error("Remote window opening is not available")
|
||||
}
|
||||
await opener(payload)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"notifications:show",
|
||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||
import http from "node:http"
|
||||
import https from "node:https"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
@@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename)
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
function configureDevStoragePaths() {
|
||||
if (app.isPackaged) {
|
||||
return
|
||||
}
|
||||
|
||||
const appName = "CodeNomad"
|
||||
|
||||
try {
|
||||
app.setName(appName)
|
||||
|
||||
const userDataPath = join(app.getPath("appData"), appName)
|
||||
const sessionDataPath = join(userDataPath, "session-data")
|
||||
|
||||
mkdirSync(userDataPath, { recursive: true })
|
||||
mkdirSync(sessionDataPath, { recursive: true })
|
||||
|
||||
app.setPath("userData", userDataPath)
|
||||
app.setPath("sessionData", sessionDataPath)
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to configure dev storage paths", error)
|
||||
}
|
||||
}
|
||||
|
||||
configureDevStoragePaths()
|
||||
|
||||
const cliManager = new CliProcessManager()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentCliUrl: string | null = null
|
||||
@@ -21,6 +46,8 @@ let pendingCliUrl: string | null = null
|
||||
let pendingBootstrapToken: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
@@ -93,8 +120,13 @@ function loadLoadingScreen(window: BrowserWindow) {
|
||||
})
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(): string[] {
|
||||
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||
const origins = new Set<string>()
|
||||
if (window) {
|
||||
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
|
||||
origins.add(origin)
|
||||
}
|
||||
}
|
||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||
for (const candidate of rendererCandidates) {
|
||||
if (!candidate) {
|
||||
@@ -109,13 +141,13 @@ function getAllowedRendererOrigins(): string[] {
|
||||
return Array.from(origins)
|
||||
}
|
||||
|
||||
function shouldOpenExternally(url: string): boolean {
|
||||
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return true
|
||||
}
|
||||
const allowedOrigins = getAllowedRendererOrigins()
|
||||
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||
return !allowedOrigins.includes(parsed.origin)
|
||||
} catch {
|
||||
return false
|
||||
@@ -128,7 +160,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
@@ -136,13 +168,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
})
|
||||
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
event.preventDefault()
|
||||
handleExternal(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
remoteWindowOrigins.set(window.id, new Set([origin]))
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to store allowed origin", url, error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearWindowAllowedOrigin(window: BrowserWindow) {
|
||||
remoteWindowOrigins.delete(window.id)
|
||||
}
|
||||
|
||||
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
insecureWindowOrigins.set(window.id, new Set([origin]))
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to store insecure origin", url, error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearWindowInsecureOrigin(window: BrowserWindow) {
|
||||
insecureWindowOrigins.delete(window.id)
|
||||
}
|
||||
|
||||
function isInsecureOriginAllowed(url: string) {
|
||||
try {
|
||||
const targetOrigin = new URL(url).origin
|
||||
for (const origins of insecureWindowOrigins.values()) {
|
||||
if (origins.has(targetOrigin)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
@@ -207,25 +280,30 @@ function createWindow() {
|
||||
},
|
||||
})
|
||||
|
||||
setupNavigationGuards(mainWindow)
|
||||
const window = mainWindow
|
||||
|
||||
setupNavigationGuards(window)
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
clearWindowAllowedOrigin(window)
|
||||
loadLoadingScreen(window)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
window.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupCliIPC(mainWindow, cliManager)
|
||||
createApplicationMenu(window)
|
||||
setupCliIPC(window, cliManager)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
window.on("closed", () => {
|
||||
destroyPreloadingView()
|
||||
clearWindowAllowedOrigin(window)
|
||||
clearWindowInsecureOrigin(window)
|
||||
mainWindow = null
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
@@ -322,13 +400,68 @@ function finalizeCliSwap(url: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const window = mainWindow
|
||||
showingLoadingScreen = false
|
||||
currentCliUrl = url
|
||||
setWindowAllowedOrigin(window, url)
|
||||
pendingCliUrl = null
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
function buildRemoteWindowTitle(name: string, baseUrl: string) {
|
||||
try {
|
||||
const parsed = new URL(baseUrl)
|
||||
return `${name} - ${parsed.host}`
|
||||
} catch {
|
||||
return `${name} - ${baseUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
|
||||
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
|
||||
}
|
||||
|
||||
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
|
||||
const targetUrl = new URL(payload.baseUrl)
|
||||
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
|
||||
const window = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor: "#1a1a1a",
|
||||
icon: getIconPath(),
|
||||
title,
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
setWindowAllowedOrigin(window, targetUrl.toString())
|
||||
if (payload.skipTlsVerify) {
|
||||
addWindowInsecureOrigin(window, targetUrl.toString())
|
||||
}
|
||||
|
||||
setupNavigationGuards(window)
|
||||
window.on("closed", () => {
|
||||
clearWindowAllowedOrigin(window)
|
||||
clearWindowInsecureOrigin(window)
|
||||
})
|
||||
|
||||
try {
|
||||
await window.loadURL(targetUrl.toString())
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||
let bootstrapExchangeInFlight = false
|
||||
|
||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||
@@ -351,6 +484,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
||||
}
|
||||
|
||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||
const sessionCookieName = cliManager.getAuthCookieName()
|
||||
const target = new URL("/api/auth/token", baseUrl)
|
||||
const body = JSON.stringify({ token })
|
||||
|
||||
@@ -381,14 +515,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
await session.defaultSession.cookies.set({
|
||||
url: baseUrl,
|
||||
name: SESSION_COOKIE_NAME,
|
||||
name: sessionCookieName,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
@@ -504,6 +638,17 @@ app.whenReady().then(() => {
|
||||
}
|
||||
|
||||
createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
|
||||
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||
if (isInsecureOriginAllowed(url)) {
|
||||
event.preventDefault()
|
||||
console.warn("[cli] allowing insecure remote certificate for", url, error)
|
||||
callback(true)
|
||||
return
|
||||
}
|
||||
callback(false)
|
||||
})
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
|
||||
@@ -14,6 +14,7 @@ const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = path.dirname(mainFilename)
|
||||
|
||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
@@ -129,6 +130,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
@@ -139,6 +141,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
@@ -436,6 +439,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
getAuthCookieName(): string {
|
||||
return this.authCookieName
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
@@ -532,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--generate-token"]
|
||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
|
||||
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
|
||||
@@ -23,6 +23,7 @@ const electronAPI = {
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.3.2"
|
||||
"@opencode-ai/plugin": "1.3.7"
|
||||
}
|
||||
}
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -170,6 +170,24 @@ export interface InstanceStreamEvent {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SideCarKind = "port"
|
||||
|
||||
export type SideCarPrefixMode = "strip" | "preserve"
|
||||
|
||||
export type SideCarStatus = "running" | "stopped"
|
||||
|
||||
export interface SideCar {
|
||||
id: string
|
||||
kind: SideCarKind
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: SideCarPrefixMode
|
||||
status: SideCarStatus
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface BinaryRecord {
|
||||
id: string
|
||||
path: string
|
||||
@@ -244,12 +262,40 @@ export interface VoiceModeStateResponse {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface RemoteServerProfile {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
skipTlsVerify: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastConnectedAt?: string
|
||||
}
|
||||
|
||||
export interface RemoteServerProbeRequest {
|
||||
baseUrl: string
|
||||
skipTlsVerify?: boolean
|
||||
}
|
||||
|
||||
export interface RemoteServerProbeResponse {
|
||||
ok: boolean
|
||||
reachable: boolean
|
||||
normalizedUrl: string
|
||||
skipTlsVerify: boolean
|
||||
requiresAuth: boolean
|
||||
authenticated: boolean
|
||||
error?: string
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
| "workspace.error"
|
||||
| "workspace.stopped"
|
||||
| "workspace.log"
|
||||
| "sidecar.updated"
|
||||
| "sidecar.removed"
|
||||
| "storage.configChanged"
|
||||
| "storage.stateChanged"
|
||||
| "instance.dataChanged"
|
||||
@@ -262,6 +308,8 @@ export type WorkspaceEventPayload =
|
||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.stopped"; workspaceId: string }
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "sidecar.updated"; sidecar: SideCar }
|
||||
| { type: "sidecar.removed"; sidecarId: string }
|
||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
|
||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
cookieName?: string
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore | null
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
private readonly cookieName: string
|
||||
private readonly authEnabled: boolean
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||
|
||||
if (!this.authEnabled) {
|
||||
@@ -102,13 +104,18 @@ export class AuthManager {
|
||||
}
|
||||
|
||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||
return this.getSessionFromHeaders(request.headers)
|
||||
}
|
||||
|
||||
getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null {
|
||||
if (!this.authEnabled) {
|
||||
// When auth is disabled, treat all requests as authenticated.
|
||||
// We still return a stable username so callers can display it.
|
||||
return { username: this.init.username, sessionId: "auth-disabled" }
|
||||
}
|
||||
|
||||
const cookies = parseCookies(request.headers.cookie)
|
||||
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
|
||||
const cookies = parseCookies(cookieHeader)
|
||||
const sessionId = cookies[this.cookieName]
|
||||
const session = this.sessionManager.getSession(sessionId)
|
||||
if (!session) return null
|
||||
@@ -139,6 +146,16 @@ 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) {
|
||||
const resolvedConfigPath = resolvePath(configPath)
|
||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||
|
||||
@@ -26,6 +26,7 @@ const PreferencesSchema = z
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: z.boolean().default(false),
|
||||
|
||||
@@ -24,6 +24,8 @@ export class EventBus extends EventEmitter {
|
||||
this.on("workspace.error", handler)
|
||||
this.on("workspace.stopped", handler)
|
||||
this.on("workspace.log", handler)
|
||||
this.on("sidecar.updated", handler)
|
||||
this.on("sidecar.removed", handler)
|
||||
this.on("storage.configChanged", handler)
|
||||
this.on("storage.stateChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
@@ -35,6 +37,8 @@ export class EventBus extends EventEmitter {
|
||||
this.off("workspace.error", handler)
|
||||
this.off("workspace.stopped", handler)
|
||||
this.off("workspace.log", handler)
|
||||
this.off("sidecar.updated", handler)
|
||||
this.off("sidecar.removed", handler)
|
||||
this.off("storage.configChanged", handler)
|
||||
this.off("storage.stateChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
|
||||
@@ -19,11 +19,12 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
import { SpeechService } from "./speech/service"
|
||||
import { SideCarManager } from "./sidecars/manager"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -55,6 +56,7 @@ interface CliOptions {
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
authCookieName: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
@@ -100,6 +102,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.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(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
@@ -139,6 +146,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authCookieName: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
@@ -185,6 +193,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
authCookieName: parsed.authCookieName,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||
}
|
||||
@@ -266,6 +275,7 @@ async function main() {
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
cookieName: options.authCookieName,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
@@ -306,6 +316,11 @@ async function main() {
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||
const sidecarManager = new SideCarManager({
|
||||
settings,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "sidecars" }),
|
||||
})
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
@@ -391,6 +406,7 @@ async function main() {
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
@@ -412,6 +428,7 @@ async function main() {
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
@@ -442,18 +459,22 @@ async function main() {
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
let remoteHost = options.host
|
||||
if (wantsAll) {
|
||||
if (options.host === "0.0.0.0") {
|
||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteAddresses = resolved.userVisible
|
||||
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
if (!remoteUrl) {
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
@@ -464,7 +485,9 @@ async function main() {
|
||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||
|
||||
if (serverMeta.remotePort && remoteUrl) {
|
||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
serverMeta.addresses = remoteAddresses.length
|
||||
? remoteAddresses
|
||||
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
@@ -472,6 +495,16 @@ async function main() {
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (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) {
|
||||
@@ -495,6 +528,12 @@ async function main() {
|
||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await sidecarManager.shutdown()
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "SideCar manager shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import cors from "@fastify/cors"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import fs from "fs"
|
||||
import { connect as connectTcp, type Socket } from "net"
|
||||
import path from "path"
|
||||
import { connect as connectTls, type TLSSocket } from "tls"
|
||||
import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
@@ -22,6 +24,8 @@ import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { registerSpeechRoutes } from "./routes/speech"
|
||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
@@ -32,6 +36,7 @@ import type { SpeechService } from "../speech/service"
|
||||
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||
import { PluginChannelManager } from "../plugins/channel"
|
||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||
import type { SideCarManager } from "../sidecars/manager"
|
||||
|
||||
interface HttpServerDeps {
|
||||
bindHost: string
|
||||
@@ -47,6 +52,7 @@ interface HttpServerDeps {
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
speechService: SpeechService
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
@@ -203,7 +209,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
|
||||
if (requiresAuthForApi && !session) {
|
||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||
@@ -270,7 +276,15 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||
setupSideCarWebSocketProxy(app, {
|
||||
sidecarManager: deps.sidecarManager,
|
||||
authManager: deps.authManager,
|
||||
logger: proxyLogger,
|
||||
})
|
||||
registerPluginRoutes(app, {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
@@ -353,6 +367,68 @@ interface InstanceProxyDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarProxyDeps {
|
||||
sidecarManager: SideCarManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
|
||||
authManager: AuthManager
|
||||
}
|
||||
|
||||
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
|
||||
const proxyBaseHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxySideCarRequest({
|
||||
request,
|
||||
reply,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
logger: deps.logger,
|
||||
pathSuffix: "",
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxySideCarRequest({
|
||||
request,
|
||||
reply,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
logger: deps.logger,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
app.all("/sidecars/:id", proxyBaseHandler)
|
||||
app.all("/sidecars/:id/*", proxyWildcardHandler)
|
||||
}
|
||||
|
||||
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
|
||||
app.server.on("upgrade", (request, socket, head) => {
|
||||
const rawUrl = request.url ?? "/"
|
||||
const parsed = parseSideCarUpgradePath(rawUrl)
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
void proxySideCarWebSocketUpgrade({
|
||||
request,
|
||||
socket: socket as Socket,
|
||||
head,
|
||||
sidecarId: parsed.sidecarId,
|
||||
incomingPath: parsed.pathname,
|
||||
search: parsed.search,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
authManager: deps.authManager,
|
||||
logger: deps.logger,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||
app.register(async (instance) => {
|
||||
instance.removeAllContentTypeParsers()
|
||||
@@ -837,3 +913,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function proxySideCarRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
sidecarManager: SideCarManager
|
||||
logger: Logger
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const sidecarId = (args.request.params as { id?: string }).id ?? ""
|
||||
const sidecar = await args.sidecarManager.get(sidecarId)
|
||||
if (!sidecar) {
|
||||
args.reply.code(404).send({ error: "SideCar not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
|
||||
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
|
||||
const pathSuffix = args.pathSuffix ?? ""
|
||||
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
|
||||
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
|
||||
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
|
||||
const targetUrl = `${targetOrigin}${targetPath}`
|
||||
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
|
||||
|
||||
await args.reply.from(targetUrl, {
|
||||
rewriteRequestHeaders: (_originalRequest, headers) =>
|
||||
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
|
||||
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
||||
onError: (reply, { error }) => {
|
||||
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send({ error: "SideCar proxy failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(rawUrl, "http://localhost")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
sidecarId: decodeURIComponent(match[1] ?? ""),
|
||||
pathname: parsed.pathname,
|
||||
search: parsed.search,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function proxySideCarWebSocketUpgrade(args: {
|
||||
request: import("http").IncomingMessage
|
||||
socket: Socket
|
||||
head: Buffer
|
||||
sidecarId: string
|
||||
incomingPath: string
|
||||
search: string
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
logger: Logger
|
||||
}) {
|
||||
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
|
||||
|
||||
if (!isWebSocketUpgradeRequest(request)) {
|
||||
rejectUpgrade(socket, 400, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
const session = authManager.getSessionFromHeaders(request.headers)
|
||||
if (!session) {
|
||||
rejectUpgrade(socket, 401, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
const sidecar = await sidecarManager.get(sidecarId)
|
||||
if (!sidecar) {
|
||||
rejectUpgrade(socket, 404, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
|
||||
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
|
||||
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
|
||||
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
|
||||
|
||||
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
|
||||
|
||||
const closeBoth = () => {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy()
|
||||
}
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
upstream.once("error", (error) => {
|
||||
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
|
||||
rejectUpgrade(socket, 502, "Bad Gateway")
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
socket.once("error", (error) => {
|
||||
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
upstream.once(readyEvent, () => {
|
||||
try {
|
||||
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
|
||||
if (head.length > 0) {
|
||||
upstream.write(head)
|
||||
}
|
||||
upstream.pipe(socket)
|
||||
socket.pipe(upstream)
|
||||
} catch (error) {
|
||||
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
|
||||
closeBoth()
|
||||
}
|
||||
})
|
||||
|
||||
upstream.once("close", () => {
|
||||
if (!socket.destroyed) {
|
||||
socket.end()
|
||||
}
|
||||
})
|
||||
|
||||
socket.once("close", () => {
|
||||
if (!upstream.destroyed) {
|
||||
upstream.end()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
|
||||
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
|
||||
if (targetUrl.protocol === "https:") {
|
||||
return {
|
||||
socket: connectTls({
|
||||
host: targetUrl.hostname,
|
||||
port,
|
||||
servername: targetUrl.hostname,
|
||||
}),
|
||||
readyEvent: "secureConnect",
|
||||
}
|
||||
}
|
||||
return {
|
||||
socket: connectTcp(port, targetUrl.hostname),
|
||||
readyEvent: "connect",
|
||||
}
|
||||
}
|
||||
|
||||
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
|
||||
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
|
||||
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
|
||||
const headerLines: string[] = []
|
||||
const rawHeaders = request.rawHeaders ?? []
|
||||
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||
|
||||
for (let index = 0; index < rawHeaders.length; index += 2) {
|
||||
const key = rawHeaders[index]
|
||||
const value = rawHeaders[index + 1]
|
||||
if (!key || value === undefined) continue
|
||||
const lower = key.toLowerCase()
|
||||
if (blockedHeaders.has(lower)) continue
|
||||
if (lower === "origin") {
|
||||
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
|
||||
continue
|
||||
}
|
||||
headerLines.push(`${key}: ${value}\r\n`)
|
||||
}
|
||||
|
||||
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
|
||||
headerLines.push(`Host: ${hostValue}\r\n`)
|
||||
headerLines.push("\r\n")
|
||||
|
||||
return requestLine + headerLines.join("")
|
||||
}
|
||||
|
||||
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
|
||||
const upgrade = request.headers.upgrade
|
||||
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
|
||||
return false
|
||||
}
|
||||
const connection = request.headers.connection
|
||||
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
|
||||
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
|
||||
if (socket.destroyed) {
|
||||
return
|
||||
}
|
||||
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||
socket.destroy()
|
||||
}
|
||||
|
||||
function rewriteSideCarResponseHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
sidecarId: string,
|
||||
targetOrigin: string,
|
||||
prefixMode: "strip" | "preserve",
|
||||
) {
|
||||
if (prefixMode === "preserve") {
|
||||
return headers
|
||||
}
|
||||
|
||||
const next = { ...headers }
|
||||
const locationHeader = next.location
|
||||
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
|
||||
if (!location) {
|
||||
return next
|
||||
}
|
||||
|
||||
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
|
||||
|
||||
if (location.startsWith("/")) {
|
||||
next.location = `${publicBase}${location}`
|
||||
return next
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(location)
|
||||
if (parsed.origin === targetOrigin) {
|
||||
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
}
|
||||
} catch {
|
||||
// Relative redirects should continue to resolve against the public sidecar path.
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function sanitizeSideCarProxyRequestHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
targetOrigin: string,
|
||||
): Record<string, string | string[] | undefined> {
|
||||
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||
const next: Record<string, string | string[] | undefined> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!value) continue
|
||||
if (blockedHeaders.has(key.toLowerCase())) continue
|
||||
next[key] = value
|
||||
}
|
||||
|
||||
next.origin = targetOrigin
|
||||
return next
|
||||
}
|
||||
|
||||
function getBlockedSideCarRequestHeaders(): Set<string> {
|
||||
return new Set([
|
||||
"host",
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import os from "os"
|
||||
import type { NetworkAddress } from "../api-types"
|
||||
|
||||
export interface ResolvedRemoteAddresses {
|
||||
all: NetworkAddress[]
|
||||
userVisible: NetworkAddress[]
|
||||
primaryRemoteUrl?: string
|
||||
}
|
||||
|
||||
export function resolveNetworkAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
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 {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
import { resolveNetworkAddresses } from "../network-addresses"
|
||||
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const localPort = resolveLocalPort(meta)
|
||||
const remote = resolveRemote(meta)
|
||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
||||
|
||||
return {
|
||||
...meta,
|
||||
localPort,
|
||||
remotePort: remote?.port,
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
166
packages/server/src/server/routes/remote-servers.ts
Normal file
166
packages/server/src/server/routes/remote-servers.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Agent, fetch } from "undici"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { Logger } from "../../logger"
|
||||
import type { RemoteServerProbeResponse } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const ProbeSchema = z.object({
|
||||
baseUrl: z.string().min(1),
|
||||
skipTlsVerify: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.post("/api/remote-servers/probe", async (request, reply) => {
|
||||
try {
|
||||
const body = ProbeSchema.parse(request.body ?? {})
|
||||
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||
} catch (error) {
|
||||
deps.logger.warn({ err: error }, "Failed to probe remote server")
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid request" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
|
||||
const normalizedUrl = normalizeBaseUrl(baseUrl)
|
||||
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||
|
||||
try {
|
||||
const response = await fetch(probeUrl, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: `Remote server returned HTTP ${response.status}`,
|
||||
errorCode: "http_error",
|
||||
}
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { authenticated?: unknown }
|
||||
if (typeof payload?.authenticated !== "boolean") {
|
||||
return {
|
||||
ok: false,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: "Remote server did not return a valid CodeNomad auth response",
|
||||
errorCode: "invalid_server",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: !payload.authenticated,
|
||||
authenticated: payload.authenticated,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = describeProbeError(error)
|
||||
return {
|
||||
ok: false,
|
||||
reachable: false,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: message.message,
|
||||
errorCode: message.code,
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
await dispatcher?.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(input: string): string {
|
||||
const parsed = new URL(input.trim())
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Server URL must use http:// or https://")
|
||||
}
|
||||
|
||||
parsed.hash = ""
|
||||
parsed.search = ""
|
||||
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
|
||||
const value = parsed.toString()
|
||||
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function describeProbeError(error: unknown): { code: string; message: string } {
|
||||
const chain = unwrapErrorChain(error)
|
||||
const detailed =
|
||||
chain.find((entry) => {
|
||||
const code = (entry?.code ?? "").toString()
|
||||
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
|
||||
}) ?? chain[0]
|
||||
|
||||
const code = (detailed?.code ?? "").toString()
|
||||
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
|
||||
|
||||
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
|
||||
return {
|
||||
code: "tls_error",
|
||||
message: "Certificate check failed while connecting to the remote server.",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code:
|
||||
code === "ERR_INVALID_URL"
|
||||
? "invalid_url"
|
||||
: code === "ECONNREFUSED"
|
||||
? "connection_refused"
|
||||
: code === "ENOTFOUND"
|
||||
? "dns_error"
|
||||
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
|
||||
? "timeout"
|
||||
: code
|
||||
? code.toLowerCase()
|
||||
: "probe_failed",
|
||||
message: exactMessage || "Failed to connect to the remote server.",
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
|
||||
const results: Array<{ code?: unknown; message?: string }> = []
|
||||
let current: unknown = error
|
||||
const seen = new Set<unknown>()
|
||||
|
||||
while (current && typeof current === "object" && !seen.has(current)) {
|
||||
seen.add(current)
|
||||
const entry = current as { code?: unknown; message?: string; cause?: unknown }
|
||||
results.push({ code: entry.code, message: entry.message })
|
||||
current = entry.cause
|
||||
}
|
||||
|
||||
if (results.length === 0 && error instanceof Error) {
|
||||
results.push({ message: error.message })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
56
packages/server/src/server/routes/sidecars.ts
Normal file
56
packages/server/src/server/routes/sidecars.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { SideCarManager } from "../../sidecars/manager"
|
||||
|
||||
interface RouteDeps {
|
||||
sidecarManager: SideCarManager
|
||||
}
|
||||
|
||||
const SideCarCreateSchema = z.object({
|
||||
kind: z.literal("port").default("port"),
|
||||
name: z.string().trim().min(1),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
insecure: z.boolean().default(false),
|
||||
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
|
||||
})
|
||||
|
||||
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
|
||||
message: "At least one field is required",
|
||||
})
|
||||
|
||||
export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/sidecars", async () => {
|
||||
return { sidecars: await deps.sidecarManager.list() }
|
||||
})
|
||||
|
||||
app.post("/api/sidecars", async (request, reply) => {
|
||||
try {
|
||||
const body = SideCarCreateSchema.parse(request.body ?? {})
|
||||
const sidecar = await deps.sidecarManager.create(body)
|
||||
reply.code(201)
|
||||
return sidecar
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Failed to create SideCar" }
|
||||
}
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||
try {
|
||||
const body = SideCarUpdateSchema.parse(request.body ?? {})
|
||||
return await deps.sidecarManager.update(request.params.id, body)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Failed to update SideCar" }
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||
const removed = await deps.sidecarManager.delete(request.params.id)
|
||||
if (!removed) {
|
||||
reply.code(404)
|
||||
return { error: "SideCar not found" }
|
||||
}
|
||||
reply.code(204)
|
||||
})
|
||||
}
|
||||
@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
||||
if (typeof listeningMode === "string") {
|
||||
serverConfig.listeningMode = listeningMode
|
||||
}
|
||||
const logLevel = preferences.logLevel
|
||||
if (typeof logLevel === "string") {
|
||||
serverConfig.logLevel = logLevel
|
||||
}
|
||||
const lastUsedBinary = preferences.lastUsedBinary
|
||||
if (typeof lastUsedBinary === "string") {
|
||||
serverConfig.opencodeBinary = lastUsedBinary
|
||||
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
||||
const moved = new Set([
|
||||
"environmentVariables",
|
||||
"listeningMode",
|
||||
"logLevel",
|
||||
"lastUsedBinary",
|
||||
"modelRecents",
|
||||
"modelFavorites",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Logger } from "../logger"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { ConfigLocation } from "../config/location"
|
||||
import { z } from "zod"
|
||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||
import { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
@@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
const CanonicalLogLevelSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
|
||||
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
|
||||
)
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
|
||||
if (!isPlainObject(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const next: SettingsDoc = { ...value }
|
||||
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
|
||||
if (parsedLogLevel.success) {
|
||||
next.logLevel = parsedLogLevel.data
|
||||
} else if (next.logLevel !== undefined) {
|
||||
next.logLevel = "DEBUG"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
|
||||
if (!isPlainObject(doc)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (!isPlainObject(doc.server)) {
|
||||
return doc
|
||||
}
|
||||
|
||||
return {
|
||||
...doc,
|
||||
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsService {
|
||||
private readonly configStore: YamlDocStore
|
||||
private readonly stateStore: YamlDocStore
|
||||
@@ -23,22 +72,44 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
getDoc(kind: DocKind): SettingsDoc {
|
||||
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
||||
if (kind !== "config") {
|
||||
return this.stateStore.get()
|
||||
}
|
||||
|
||||
const current = this.configStore.get()
|
||||
const normalized = normalizeConfigDoc(current)
|
||||
if (!isDeepEqual(current, normalized)) {
|
||||
this.configStore.replace(normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
||||
const updated =
|
||||
kind === "config"
|
||||
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||
: this.stateStore.mergePatch(patch)
|
||||
this.publish(kind, "*")
|
||||
return updated
|
||||
}
|
||||
|
||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
||||
if (kind !== "config") {
|
||||
return this.stateStore.getOwner(owner)
|
||||
}
|
||||
|
||||
return owner === "server"
|
||||
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
|
||||
: this.getDoc("config")[owner] as SettingsDoc
|
||||
}
|
||||
|
||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||
const updated =
|
||||
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
||||
kind === "config"
|
||||
? owner === "server"
|
||||
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||
: this.configStore.mergePatchOwner(owner, patch)
|
||||
: this.stateStore.mergePatchOwner(owner, patch)
|
||||
this.publish(kind, owner, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
256
packages/server/src/sidecars/manager.ts
Normal file
256
packages/server/src/sidecars/manager.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { connect } from "net"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { Logger } from "../logger"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types"
|
||||
|
||||
interface SideCarManagerOptions {
|
||||
settings: SettingsService
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarConfigRecord {
|
||||
id: string
|
||||
kind: SideCarKind
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: SideCarPrefixMode
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface SideCarRuntimeRecord {
|
||||
status: SideCarStatus
|
||||
}
|
||||
|
||||
export class SideCarManager {
|
||||
private readonly configs = new Map<string, SideCarConfigRecord>()
|
||||
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
|
||||
|
||||
constructor(private readonly options: SideCarManagerOptions) {
|
||||
for (const record of this.loadConfiguredSideCars()) {
|
||||
this.configs.set(record.id, record)
|
||||
this.runtime.set(record.id, { status: "stopped" })
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
for (const record of this.configs.values()) {
|
||||
void this.refreshPortSideCar(record.id).catch((error) => {
|
||||
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async list(): Promise<SideCar[]> {
|
||||
await this.refreshPortStatuses()
|
||||
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<SideCar | undefined> {
|
||||
if (!this.configs.has(id)) return undefined
|
||||
await this.refreshPortSideCar(id)
|
||||
return this.toSideCar(this.requireConfig(id))
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
kind: SideCarKind
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: SideCarPrefixMode
|
||||
}): Promise<SideCar> {
|
||||
const normalizedName = input.name.trim()
|
||||
const id = this.buildSideCarId(normalizedName)
|
||||
if (this.configs.has(id)) {
|
||||
throw new Error(`SideCar '${id}' already exists`)
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const record: SideCarConfigRecord = {
|
||||
id,
|
||||
kind: input.kind,
|
||||
name: normalizedName,
|
||||
port: input.port,
|
||||
insecure: input.insecure,
|
||||
prefixMode: input.prefixMode,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
this.configs.set(record.id, record)
|
||||
this.runtime.set(record.id, { status: "stopped" })
|
||||
this.persistConfigs()
|
||||
await this.refreshPortSideCar(record.id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: Partial<{
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: SideCarPrefixMode
|
||||
}>,
|
||||
): Promise<SideCar> {
|
||||
const record = this.requireConfig(id)
|
||||
|
||||
record.name = typeof input.name === "string" ? input.name.trim() : record.name
|
||||
record.port = typeof input.port === "number" ? input.port : record.port
|
||||
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
|
||||
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
|
||||
record.updatedAt = new Date().toISOString()
|
||||
|
||||
this.persistConfigs()
|
||||
await this.refreshPortSideCar(id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const record = this.configs.get(id)
|
||||
if (!record) return false
|
||||
|
||||
this.configs.delete(id)
|
||||
this.runtime.delete(id)
|
||||
this.persistConfigs()
|
||||
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
|
||||
return true
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
return
|
||||
}
|
||||
|
||||
buildTargetOrigin(sidecar: Pick<SideCar, "port" | "insecure">): string {
|
||||
const protocol = sidecar.insecure ? "http" : "https"
|
||||
return `${protocol}://127.0.0.1:${sidecar.port}`
|
||||
}
|
||||
|
||||
buildProxyBasePath(id: string): string {
|
||||
return `/sidecars/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
buildTargetPath(id: string, incomingPath: string, search = ""): string {
|
||||
const record = this.requireConfig(id)
|
||||
const publicBase = this.buildProxyBasePath(id)
|
||||
const normalizedPath = incomingPath || publicBase
|
||||
|
||||
if (record.prefixMode === "preserve") {
|
||||
return `${normalizedPath}${search}`
|
||||
}
|
||||
|
||||
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath
|
||||
if (!stripped || stripped === "/") {
|
||||
stripped = "/"
|
||||
} else if (!stripped.startsWith("/")) {
|
||||
stripped = `/${stripped}`
|
||||
}
|
||||
return `${stripped}${search}`
|
||||
}
|
||||
|
||||
private async refreshPortStatuses() {
|
||||
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)))
|
||||
}
|
||||
|
||||
private async refreshPortSideCar(id: string) {
|
||||
const record = this.configs.get(id)
|
||||
if (!record) return
|
||||
const isAvailable = await this.isPortAvailable(record.port)
|
||||
const current = this.runtime.get(id)
|
||||
const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped"
|
||||
if (current?.status === nextStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
this.runtime.set(id, { status: nextStatus })
|
||||
record.updatedAt = new Date().toISOString()
|
||||
this.publish(id)
|
||||
}
|
||||
|
||||
private publish(id: string) {
|
||||
const record = this.configs.get(id)
|
||||
if (!record) return
|
||||
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) })
|
||||
}
|
||||
|
||||
private toSideCar(record: SideCarConfigRecord): SideCar {
|
||||
const runtime = this.runtime.get(record.id)
|
||||
return {
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
name: record.name,
|
||||
port: record.port,
|
||||
insecure: record.insecure,
|
||||
prefixMode: record.prefixMode,
|
||||
status: runtime?.status ?? "stopped",
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
private requireConfig(id: string): SideCarConfigRecord {
|
||||
const record = this.configs.get(id)
|
||||
if (!record) {
|
||||
throw new Error("SideCar not found")
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
private persistConfigs() {
|
||||
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }))
|
||||
this.options.settings.mergePatchOwner("config", "server", { sidecars })
|
||||
}
|
||||
|
||||
private loadConfiguredSideCars(): SideCarConfigRecord[] {
|
||||
const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown }
|
||||
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : []
|
||||
const records: SideCarConfigRecord[] = []
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const record = item as Record<string, unknown>
|
||||
const kind = record.kind === "port" ? "port" : null
|
||||
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null
|
||||
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null
|
||||
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null
|
||||
if (!kind || !id || !name || !port) continue
|
||||
|
||||
const insecure = record.insecure === true
|
||||
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip"
|
||||
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString()
|
||||
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt
|
||||
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt })
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||
socket.end()
|
||||
resolve(true)
|
||||
})
|
||||
socket.once("error", () => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private buildSideCarId(name: string): string {
|
||||
const normalized = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error("SideCar name must include letters or numbers")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
@@ -142,12 +142,15 @@ export class WorkspaceManager {
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
const logLevel = (serverConfig as any)?.logLevel
|
||||
|
||||
try {
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
environment,
|
||||
logLevel,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ interface LaunchOptions {
|
||||
folder: string
|
||||
binaryPath: string
|
||||
environment?: Record<string, string>
|
||||
logLevel?: string
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
@@ -139,7 +140,8 @@ export class WorkspaceRuntime {
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
|
||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||
|
||||
2
packages/tauri-app/Cargo.lock
generated
2
packages/tauri-app/Cargo.lock
generated
@@ -458,7 +458,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.12.3"
|
||||
version = "0.13.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.12.3"
|
||||
version = "0.13.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -2378,6 +2378,72 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -48,7 +48,7 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
#[cfg(windows)]
|
||||
@@ -124,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||
fn exchange_bootstrap_token(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
cookie_name: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||
@@ -159,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
for line in lines {
|
||||
// handle case-insensitive header name
|
||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
}
|
||||
@@ -172,11 +176,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||
fn set_session_cookie(
|
||||
app: &AppHandle,
|
||||
base_url: &str,
|
||||
cookie_name: &str,
|
||||
session_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||
|
||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
||||
.domain(domain)
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
@@ -190,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
|
||||
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";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -503,7 +522,8 @@ impl CliProcessManager {
|
||||
"resolved CLI entry runner={:?} entry={} host={}",
|
||||
resolution.runner, resolution.entry, host
|
||||
));
|
||||
let args = resolution.build_args(dev, &host);
|
||||
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
||||
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
@@ -514,7 +534,9 @@ impl CliProcessManager {
|
||||
log_line(&format!("using cwd={}", c.display()));
|
||||
}
|
||||
|
||||
let command_info = if supports_user_shell() {
|
||||
let use_user_shell = supports_user_shell();
|
||||
|
||||
let command_info = if use_user_shell {
|
||||
log_line("spawning via user shell");
|
||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||
} else {
|
||||
@@ -525,7 +547,7 @@ impl CliProcessManager {
|
||||
})
|
||||
};
|
||||
|
||||
if !supports_user_shell() {
|
||||
if !use_user_shell {
|
||||
if which::which(&resolution.node_binary).is_err() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Node binary not found. Make sure Node.js is installed."
|
||||
@@ -539,6 +561,8 @@ impl CliProcessManager {
|
||||
let mut c = Command::new(&cmd.shell);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.env_remove("npm_config_prefix")
|
||||
.env_remove("NPM_CONFIG_PREFIX")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
@@ -584,6 +608,7 @@ impl CliProcessManager {
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let token_clone = bootstrap_token.clone();
|
||||
let auth_cookie_name_clone = auth_cookie_name.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let stdout = child_clone
|
||||
@@ -598,24 +623,41 @@ impl CliProcessManager {
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -731,10 +773,10 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
loop {
|
||||
@@ -766,39 +808,17 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.map(|m| m.as_str().to_string())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
auth_cookie_name,
|
||||
url,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.to_lowercase().contains("http server listening") {
|
||||
if let Some(port) = http_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{port}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{}", port),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -811,6 +831,7 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
base_url: String,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
@@ -834,9 +855,11 @@ impl CliProcessManager {
|
||||
if scheme.as_deref() != Some("http") {
|
||||
navigate_main(app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
if let Err(err) =
|
||||
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
||||
{
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
@@ -932,12 +955,15 @@ impl CliEntry {
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--auth-cookie-name".to_string(),
|
||||
auth_cookie_name.to_string(),
|
||||
"--generate-token".to_string(),
|
||||
"--unrestricted-root".to_string(),
|
||||
];
|
||||
|
||||
if dev {
|
||||
@@ -993,27 +1019,50 @@ impl CliEntry {
|
||||
}
|
||||
|
||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let mut candidates = vec![
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
std::env::current_exe().ok().and_then(|ex| {
|
||||
ex.parent()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
||||
}),
|
||||
];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
|
||||
}
|
||||
}
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("packages/server/src/index.ts")),
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
@@ -1115,11 +1164,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if shell_name.contains("zsh") {
|
||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||
} else {
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
let _ = shell_name;
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
|
||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||
|
||||
@@ -6,13 +6,14 @@ use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::Webview;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry};
|
||||
use tauri_plugin_global_shortcut::{
|
||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||
};
|
||||
@@ -41,6 +42,16 @@ pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub zoom_level: Mutex<f64>,
|
||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RemoteWindowPayload {
|
||||
id: String,
|
||||
name: String,
|
||||
base_url: String,
|
||||
skip_tls_verify: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -118,11 +129,28 @@ fn should_allow_internal(url: &Url) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
fn should_allow_window_origin<R: Runtime>(app_handle: &AppHandle<R>, window_label: &str, url: &Url) -> bool {
|
||||
if should_allow_internal(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let Ok(allowed) = state.remote_origins.lock() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(origin) = allowed.get(window_label) {
|
||||
return origin == &url.origin().ascii_serialization();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
let window_label = webview.label().to_string();
|
||||
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Err(err) = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
@@ -133,6 +161,53 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
|
||||
return Err(
|
||||
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
|
||||
let label = format!("remote-{}", payload.id);
|
||||
let title = format!("{} - {}", payload.name, parsed.host_str().unwrap_or(payload.base_url.as_str()));
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
let _ = existing.navigate(parsed.clone());
|
||||
let _ = existing.set_title(&title);
|
||||
let _ = existing.show();
|
||||
let _ = existing.unminimize();
|
||||
let _ = existing.set_focus();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.state::<AppState>()
|
||||
.remote_origins
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(label.clone(), parsed.origin().ascii_serialization());
|
||||
|
||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let app_handle = app.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Destroyed = event {
|
||||
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||
origins.remove(&label);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
@@ -286,6 +361,7 @@ fn main() {
|
||||
manager: CliProcessManager::new(),
|
||||
wake_lock: Mutex::new(None),
|
||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||
remote_origins: Mutex::new(HashMap::new()),
|
||||
})
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
@@ -323,7 +399,8 @@ fn main() {
|
||||
cli_get_status,
|
||||
cli_restart,
|
||||
wake_lock_start,
|
||||
wake_lock_stop
|
||||
wake_lock_stop,
|
||||
open_remote_window
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
@@ -455,11 +532,24 @@ fn main() {
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} => {
|
||||
// Ensure we have time to stop the CLI process before the app exits.
|
||||
// Let windows close normally. App shutdown is handled only after the
|
||||
// last window is actually gone so remote windows can outlive `main`.
|
||||
let _ = api;
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
..
|
||||
} => {
|
||||
if !app_handle.webview_windows().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the CLI only when the final window is gone and the app is
|
||||
// truly exiting.
|
||||
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
api.prevent_close();
|
||||
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.3",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
"frontendDist": "resources/ui-loading"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
@@ -33,9 +30,13 @@
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"scope": ["**"]
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"capabilities": ["main-window-native-dialogs"]
|
||||
"capabilities": [
|
||||
"main-window-native-dialogs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -44,7 +45,17 @@
|
||||
"resources/server",
|
||||
"resources/ui-loading"
|
||||
],
|
||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||
"icon": [
|
||||
"icon.icns",
|
||||
"icon.ico",
|
||||
"icon.png"
|
||||
],
|
||||
"targets": [
|
||||
"app",
|
||||
"appimage",
|
||||
"deb",
|
||||
"rpm",
|
||||
"nsis"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { SettingsScreen } from "./components/settings-screen"
|
||||
import { SideCarPickerDialog } from "./components/sidecar-picker-dialog"
|
||||
import { SideCarView } from "./components/sidecar-view"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { showAlertDialog } from "./stores/alerts"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
@@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
showFolderSelection,
|
||||
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
|
||||
import {
|
||||
createInstance,
|
||||
instances,
|
||||
activeInstanceId,
|
||||
setActiveInstanceId,
|
||||
stopInstance,
|
||||
getActiveInstance,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
} from "./stores/instances"
|
||||
@@ -53,6 +52,22 @@ import {
|
||||
|
||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||
import { openSettings } from "./stores/settings-screen"
|
||||
import {
|
||||
closeSidecarTab,
|
||||
ensureSidecarsLoaded,
|
||||
openSidecarTab,
|
||||
} from "./stores/sidecars"
|
||||
import {
|
||||
activeAppTab,
|
||||
activeAppTabId,
|
||||
appTabs,
|
||||
ensureActiveAppTab,
|
||||
getAdjacentAppTabId,
|
||||
getAppTabById,
|
||||
selectAppTab,
|
||||
selectInstanceTab,
|
||||
selectSidecarTab,
|
||||
} from "./stores/app-tabs"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -77,6 +92,7 @@ const App: Component = () => {
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
|
||||
|
||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||
@@ -206,8 +222,7 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
instances()
|
||||
hasInstances()
|
||||
appTabs()
|
||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||
})
|
||||
|
||||
@@ -219,7 +234,15 @@ const App: Component = () => {
|
||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
createEffect(() => {
|
||||
appTabs()
|
||||
ensureActiveAppTab()
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => {
|
||||
const tab = activeAppTab()
|
||||
return tab?.kind === "instance" ? tab.instance : null
|
||||
})
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return null
|
||||
@@ -244,6 +267,7 @@ const App: Component = () => {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
selectInstanceTab(instanceId)
|
||||
setShowFolderSelection(false)
|
||||
|
||||
log.info("Created instance", {
|
||||
@@ -270,8 +294,27 @@ const App: Component = () => {
|
||||
}
|
||||
|
||||
function handleNewInstanceRequest() {
|
||||
if (hasInstances()) {
|
||||
setShowFolderSelection(true)
|
||||
setShowFolderSelection(true)
|
||||
}
|
||||
|
||||
function handleOpenSidecarPicker() {
|
||||
setSidecarPickerOpen(true)
|
||||
void ensureSidecarsLoaded()
|
||||
}
|
||||
|
||||
async function handleOpenSidecar(sidecarId: string) {
|
||||
try {
|
||||
const tab = await openSidecarTab(sidecarId)
|
||||
selectSidecarTab(tab.token)
|
||||
setShowFolderSelection(false)
|
||||
setSidecarPickerOpen(false)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
showAlertDialog(message, {
|
||||
variant: "error",
|
||||
title: t("sidecars.open.errorTitle"),
|
||||
})
|
||||
log.error("Failed to open SideCar", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +375,23 @@ const App: Component = () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseAppTab(tabId: string) {
|
||||
const tab = getAppTabById(tabId)
|
||||
if (!tab) return
|
||||
|
||||
const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId()
|
||||
|
||||
if (tab.kind === "instance") {
|
||||
await handleCloseInstance(tab.instance.id)
|
||||
} else {
|
||||
closeSidecarTab(tab.sidecarTab.token)
|
||||
}
|
||||
|
||||
if (!getAppTabById(tabId)) {
|
||||
ensureActiveAppTab(fallbackTabId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
||||
if (!instanceId || !sessionId || sessionId === "info") return
|
||||
await updateSessionAgent(instanceId, sessionId, agent)
|
||||
@@ -361,6 +421,7 @@ const App: Component = () => {
|
||||
setThinkingBlocksExpansion,
|
||||
setToolInputsVisibility,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
@@ -371,6 +432,7 @@ const App: Component = () => {
|
||||
useAppLifecycle({
|
||||
setEscapeInDebounce,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
@@ -470,52 +532,60 @@ const App: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!hasInstances()}
|
||||
when={appTabs().length === 0}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
tabs={appTabs()}
|
||||
activeTabId={activeAppTabId()}
|
||||
onSelect={selectAppTab}
|
||||
onClose={(tabId) => void handleCloseAppTab(tabId)}
|
||||
onNew={handleNewInstanceRequest}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<For each={Array.from(instances().values())}>
|
||||
{(instance) => {
|
||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||
return (
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-instance-id={instance.id}
|
||||
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||
data-instance-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
isActiveInstance={isActiveInstance()}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
<For each={appTabs()}>
|
||||
{(tab) => {
|
||||
const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection()
|
||||
return tab.kind === "instance" ? (
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-instance-id={tab.instance.id}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-kind={tab.kind}
|
||||
data-tab-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<InstanceMetadataProvider instance={tab.instance}>
|
||||
<InstanceShell
|
||||
instance={tab.instance}
|
||||
isActiveInstance={isVisible()}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(tab.instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(tab.instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-kind={tab.kind}
|
||||
data-tab-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<SideCarView tab={tab.sidecarTab} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -525,6 +595,7 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
onOpenSidecar={handleOpenSidecarPicker}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -534,6 +605,7 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
onOpenSidecar={handleOpenSidecarPicker}
|
||||
onClose={() => {
|
||||
setShowFolderSelection(false)
|
||||
clearLaunchError()
|
||||
@@ -544,6 +616,7 @@ const App: Component = () => {
|
||||
</Show>
|
||||
|
||||
<SettingsScreen />
|
||||
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { createMemo, Show, createEffect } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
import { disableCache } from "@git-diff-view/core"
|
||||
@@ -20,6 +20,7 @@ interface ToolCallDiffViewerProps {
|
||||
filePath?: string
|
||||
theme: "light" | "dark"
|
||||
mode: DiffViewMode
|
||||
wrap?: boolean
|
||||
onRendered?: () => void
|
||||
cachedHtml?: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
@@ -31,11 +32,183 @@ type DiffData = {
|
||||
hunks: string[]
|
||||
}
|
||||
|
||||
type CaptureContext = {
|
||||
theme: ToolCallDiffViewerProps["theme"]
|
||||
mode: DiffViewMode
|
||||
diffText: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) {
|
||||
const computed = window.getComputedStyle(source)
|
||||
const probe = document.createElement("span")
|
||||
probe.textContent = text || ""
|
||||
probe.style.position = "absolute"
|
||||
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) {
|
||||
@@ -67,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
const contextKey = createMemo(() => {
|
||||
const data = diffData()
|
||||
if (!data) return ""
|
||||
return `${props.theme}|${props.mode}|${props.diffText}`
|
||||
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const cachedHtml = props.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
|
||||
// and simply notify once rendered.
|
||||
props.onRendered?.()
|
||||
@@ -83,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
if (!key) return
|
||||
if (!diffContainerRef) return
|
||||
if (lastCapturedKey === key) return
|
||||
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!diffContainerRef) return
|
||||
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||
const markup = diffContainerRef.innerHTML
|
||||
if (!markup) return
|
||||
lastCapturedKey = key
|
||||
@@ -95,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
html: markup,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
wrap: props.wrap,
|
||||
})
|
||||
}
|
||||
props.onRendered?.()
|
||||
@@ -122,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||
diffViewTheme={props.theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={false}
|
||||
diffViewWrap={Boolean(props.wrap)}
|
||||
diffViewFontSize={13}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
@@ -131,7 +309,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div innerHTML={props.cachedHtml} />
|
||||
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Select } from "@kobalte/core/select"
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
@@ -14,25 +15,48 @@ import { useI18n, type Locale } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||
import { openExternalUrl } from "../lib/external-url"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
|
||||
type HomeTab = "local" | "servers"
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
onOpenSidecar?: () => void
|
||||
isLoading?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
||||
const {
|
||||
recentFolders,
|
||||
removeRecentFolder,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
serverSettings,
|
||||
remoteServers,
|
||||
saveRemoteServerProfile,
|
||||
markRemoteServerConnected,
|
||||
removeRemoteServerProfile,
|
||||
} = useConfig()
|
||||
const { t, locale } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const [activeTab, setActiveTab] = createSignal<HomeTab>("local")
|
||||
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
|
||||
const [serverName, setServerName] = createSignal("")
|
||||
const [serverUrl, setServerUrl] = createSignal("")
|
||||
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
|
||||
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
||||
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
||||
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -49,10 +73,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
]
|
||||
|
||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||
|
||||
|
||||
const folders = () => recentFolders()
|
||||
const serverList = () => remoteServers()
|
||||
const isLoading = () => Boolean(props.isLoading)
|
||||
|
||||
function getActiveListLength() {
|
||||
return activeTab() === "local" ? folders().length : serverList().length
|
||||
}
|
||||
|
||||
// Update selected binary when preferences change
|
||||
createEffect(() => {
|
||||
const lastUsed = serverSettings().opencodeBinary
|
||||
@@ -64,7 +93,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
function scrollToIndex(index: number) {
|
||||
const container = recentListRef
|
||||
if (!container) return
|
||||
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
|
||||
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
|
||||
if (!element) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
@@ -113,19 +142,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
const folderList = folders()
|
||||
|
||||
if (isBrowseShortcut) {
|
||||
e.preventDefault()
|
||||
void handleBrowse()
|
||||
return
|
||||
}
|
||||
|
||||
if (folderList.length === 0) return
|
||||
const listLength = getActiveListLength()
|
||||
if (listLength === 0) return
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
|
||||
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -138,7 +166,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
} else if (e.key === "PageDown") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -156,7 +184,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
scrollToIndex(0)
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault()
|
||||
const newIndex = folderList.length - 1
|
||||
const newIndex = listLength - 1
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -165,10 +193,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
handleEnterKey()
|
||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||
e.preventDefault()
|
||||
if (folderList.length > 0 && focusMode() === "recent") {
|
||||
const folder = folderList[selectedIndex()]
|
||||
if (folder) {
|
||||
handleRemove(folder.path)
|
||||
if (listLength > 0 && focusMode() === "recent") {
|
||||
if (activeTab() === "local") {
|
||||
const folder = folders()[selectedIndex()]
|
||||
if (folder) {
|
||||
handleRemove(folder.path)
|
||||
}
|
||||
} else {
|
||||
const server = serverList()[selectedIndex()]
|
||||
if (server) {
|
||||
removeRemoteServerProfile(server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,15 +212,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
function handleEnterKey() {
|
||||
if (isLoading()) return
|
||||
const folderList = folders()
|
||||
const index = selectedIndex()
|
||||
|
||||
const folder = folderList[index]
|
||||
if (folder) {
|
||||
handleFolderSelect(folder.path)
|
||||
if (activeTab() === "local") {
|
||||
const folder = folders()[index]
|
||||
if (folder) {
|
||||
handleFolderSelect(folder.path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const server = serverList()[index]
|
||||
if (server) {
|
||||
void handleConnectSavedServer(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
activeTab()
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("recent")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const length = getActiveListLength()
|
||||
if (length === 0) {
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedIndex() >= length) {
|
||||
setSelectedIndex(length - 1)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
@@ -236,6 +296,87 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
function resetServerDialog() {
|
||||
setServerName("")
|
||||
setServerUrl("")
|
||||
setSkipTlsVerify(false)
|
||||
setServerDialogError(null)
|
||||
}
|
||||
|
||||
function openServerDialog() {
|
||||
resetServerDialog()
|
||||
setIsServerDialogOpen(true)
|
||||
}
|
||||
|
||||
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||
const trimmedName = input.name.trim()
|
||||
const trimmedUrl = input.baseUrl.trim()
|
||||
if (!trimmedName || !trimmedUrl) {
|
||||
throw new Error(t("folderSelection.servers.dialog.errorRequired"))
|
||||
}
|
||||
|
||||
const probe = await serverApi.probeRemoteServer({
|
||||
baseUrl: trimmedUrl,
|
||||
skipTlsVerify: input.skipTlsVerify,
|
||||
})
|
||||
|
||||
if (!probe.ok) {
|
||||
throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect"))
|
||||
}
|
||||
|
||||
const profile = await saveRemoteServerProfile({
|
||||
id: input.id,
|
||||
name: trimmedName,
|
||||
baseUrl: probe.normalizedUrl,
|
||||
skipTlsVerify: input.skipTlsVerify,
|
||||
})
|
||||
|
||||
if (openWindow) {
|
||||
await openRemoteServerWindow(profile)
|
||||
await markRemoteServerConnected(profile.id)
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
async function handleSaveServer(openWindow: boolean) {
|
||||
if (isSavingServer()) return
|
||||
setIsSavingServer(true)
|
||||
setServerDialogError(null)
|
||||
try {
|
||||
await probeAndOpenServer(
|
||||
{
|
||||
name: serverName(),
|
||||
baseUrl: serverUrl(),
|
||||
skipTlsVerify: skipTlsVerify(),
|
||||
},
|
||||
openWindow,
|
||||
)
|
||||
setIsServerDialogOpen(false)
|
||||
resetServerDialog()
|
||||
} catch (error) {
|
||||
setServerDialogError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setIsSavingServer(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnectSavedServer(id: string) {
|
||||
const target = remoteServers().find((entry) => entry.id === id)
|
||||
if (!target || connectingServerId()) return
|
||||
setConnectingServerId(id)
|
||||
try {
|
||||
await probeAndOpenServer(target, true)
|
||||
} catch (error) {
|
||||
showAlertDialog(error instanceof Error ? error.message : String(error), {
|
||||
title: t("folderSelection.servers.errorTitle"),
|
||||
variant: "warning",
|
||||
})
|
||||
} finally {
|
||||
setConnectingServerId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
@@ -443,7 +584,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
aria-label={t("folderSelection.links.githubStars")}
|
||||
title={t("folderSelection.links.githubStars")}
|
||||
title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
@@ -476,90 +617,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||
{/* Right column: recent folders */}
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
||||
<p class="panel-subtitle">
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div class="panel-header !gap-0 !p-0">
|
||||
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
||||
<button
|
||||
type="button"
|
||||
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"text-primary": activeTab() === "local",
|
||||
"text-muted hover:text-secondary": activeTab() !== "local",
|
||||
}}
|
||||
style={{
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("local")}
|
||||
>
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
class="panel-title text-base"
|
||||
style={{
|
||||
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
{t("folderSelection.recent.title")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"text-primary": activeTab() === "servers",
|
||||
"text-muted hover:text-secondary": activeTab() !== "servers",
|
||||
}}
|
||||
style={{
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("servers")}
|
||||
>
|
||||
<div
|
||||
class="panel-title text-base"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.tabs.servers")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={activeTab() === "local"}
|
||||
fallback={
|
||||
<Show
|
||||
when={remoteServers().length > 0}
|
||||
fallback={
|
||||
<div class="panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Globe class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
type="button"
|
||||
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||
onClick={openServerDialog}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{splitFolderPath(folder.path).baseName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||
{getDisplayPath(folder.path)}
|
||||
</span>
|
||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={remoteServers()}>
|
||||
{(server, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-list-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
onClick={() => void handleConnectSavedServer(server.id)}
|
||||
onMouseEnter={() => {
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd">↵</kbd></Show>}>
|
||||
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeRemoteServerProfile(server.id)}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.servers.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-list-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{splitFolderPath(folder.path).baseName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||
{getDisplayPath(folder.path)}
|
||||
</span>
|
||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -567,11 +841,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
||||
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="panel-body flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
@@ -588,6 +862,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onOpenSidecar?.()}
|
||||
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
<span>{t("folderSelection.sidecars.button")}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openServerDialog}
|
||||
class="button-primary w-full flex items-center justify-center text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OpenCode settings section */}
|
||||
@@ -663,6 +958,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
|
||||
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">
|
||||
{t("folderSelection.servers.dialog.title")}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
{t("folderSelection.servers.dialog.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||
<span>{t("folderSelection.servers.dialog.name")}</span>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
value={serverName()}
|
||||
onInput={(event) => setServerName(event.currentTarget.value)}
|
||||
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||
<span>{t("folderSelection.servers.dialog.url")}</span>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
value={serverUrl()}
|
||||
onInput={(event) => setServerUrl(event.currentTarget.value)}
|
||||
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-3 text-sm text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipTlsVerify()}
|
||||
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
|
||||
</label>
|
||||
|
||||
<Show when={serverDialogError()}>
|
||||
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
|
||||
{t("folderSelection.servers.dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary w-auto px-4"
|
||||
disabled={isSavingServer()}
|
||||
onClick={() => void handleSaveServer(false)}
|
||||
>
|
||||
{t("folderSelection.servers.dialog.save")}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary w-auto px-4"
|
||||
disabled={isSavingServer()}
|
||||
onClick={() => void handleSaveServer(true)}
|
||||
>
|
||||
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
{t("folderSelection.servers.dialog.connecting")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||
@@ -9,12 +8,13 @@ import { useI18n } from "../lib/i18n"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { openSettings } from "../stores/settings-screen"
|
||||
import type { AppTabRecord } from "../stores/app-tabs"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
tabs: AppTabRecord[]
|
||||
activeTabId: string | null
|
||||
onSelect: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onNew: () => void
|
||||
}
|
||||
|
||||
@@ -42,15 +42,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
<div class="tab-scroll">
|
||||
<div class="tab-strip">
|
||||
<div class="tab-strip-tabs">
|
||||
<For each={Array.from(props.instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === props.activeInstanceId}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={() => props.onClose(id)}
|
||||
/>
|
||||
)}
|
||||
<For each={props.tabs}>
|
||||
{(tab) =>
|
||||
tab.kind === "instance" ? (
|
||||
<InstanceTab
|
||||
instance={tab.instance}
|
||||
active={tab.id === props.activeTabId}
|
||||
onSelect={() => props.onSelect(tab.id)}
|
||||
onClose={() => props.onClose(tab.id)}
|
||||
/>
|
||||
) : (
|
||||
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
|
||||
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
|
||||
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
|
||||
</button>
|
||||
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="new-tab-button"
|
||||
@@ -62,7 +72,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-strip-spacer" />
|
||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||
<Show when={props.tabs.length > 1}>
|
||||
<div class="tab-shortcuts">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||
|
||||
@@ -36,12 +36,12 @@ import { serverApi } from "../../lib/api-client"
|
||||
import { loadBackgroundProcesses } from "../../stores/background-processes"
|
||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances"
|
||||
import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances"
|
||||
import SessionSidebar from "./shell/SessionSidebar"
|
||||
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
||||
import RightPanel from "./shell/right-panel/RightPanel"
|
||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||
import { getSessionStatus } from "../../stores/session-status"
|
||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||
|
||||
import type { LayoutMode } from "./shell/types"
|
||||
@@ -57,6 +57,13 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
|
||||
import { useDrawerResize } from "./shell/useDrawerResize"
|
||||
import { useSessionCache } from "./shell/useSessionCache"
|
||||
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
|
||||
import { getPermissionSessionId } from "../../types/permission"
|
||||
import {
|
||||
canAutoRespondPermission,
|
||||
finishAutoRespondPermission,
|
||||
getPermissionAutoAcceptInFlightVersion,
|
||||
isPermissionAutoAcceptEnabled,
|
||||
} from "../../stores/permission-auto-accept"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -97,6 +104,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
|
||||
// Worktree selector manages its own dialogs.
|
||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||
@@ -230,6 +238,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
onCleanup(() => window.clearInterval(timer))
|
||||
})
|
||||
|
||||
const connectionStatus = () => sseManager.getStatus(props.instance.id)
|
||||
const connectionStatusClass = () => {
|
||||
const status = connectionStatus()
|
||||
@@ -252,6 +266,33 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return permissions + questions > 0
|
||||
})
|
||||
|
||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
||||
|
||||
createEffect(() => {
|
||||
getPermissionAutoAcceptInFlightVersion()
|
||||
|
||||
for (const permission of permissionQueue()) {
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
if (!sessionId) continue
|
||||
if (!permission?.id) continue
|
||||
if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue
|
||||
|
||||
void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once")
|
||||
.catch((error) => {
|
||||
log.error("Failed to auto-accept permission", error)
|
||||
})
|
||||
.finally(() => {
|
||||
finishAutoRespondPermission(props.instance.id, sessionId, permission.id)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const yoloModeEnabled = createMemo(() => {
|
||||
const session = activeSessionForInstance()
|
||||
if (!session) return false
|
||||
return isPermissionAutoAcceptEnabled(props.instance.id, session.id)
|
||||
})
|
||||
|
||||
const activeSessionStatusPill = createMemo(() => {
|
||||
const activeSessionId = activeSessionIdForInstance()
|
||||
if (!activeSessionId || activeSessionId === "info") return null
|
||||
@@ -272,17 +313,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
}
|
||||
|
||||
const status = getSessionStatus(props.instance.id, activeSessionId)
|
||||
const text =
|
||||
status === "working"
|
||||
const retry = getSessionRetry(props.instance.id, activeSessionId)
|
||||
const text = retry
|
||||
? (() => {
|
||||
const seconds = getRetrySeconds(retry.next, now())
|
||||
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
|
||||
})()
|
||||
: status === "working"
|
||||
? t("sessionList.status.working")
|
||||
: status === "compacting"
|
||||
? t("sessionList.status.compacting")
|
||||
: t("sessionList.status.idle")
|
||||
|
||||
return {
|
||||
className: `session-${status}`,
|
||||
className: `session-${retry ? "retrying" : status}`,
|
||||
text,
|
||||
showAlertIcon: false,
|
||||
title: retry
|
||||
? t("sessionList.status.retryTooltip", {
|
||||
message: retry.message,
|
||||
attempt: String(retry.attempt),
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -290,13 +342,39 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const pill = activeSessionStatusPill()
|
||||
if (!pill) return null
|
||||
return (
|
||||
<span class={`status-indicator session-status session-status-list ${pill.className}`}>
|
||||
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
|
||||
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{pill.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderYoloModePill = () => {
|
||||
if (!yoloModeEnabled()) return null
|
||||
return (
|
||||
<span
|
||||
class="status-indicator session-status session-status-list session-yolo-mode"
|
||||
aria-label={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||
title={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||
>
|
||||
<span class="status-dot" />
|
||||
{t("instanceShell.yoloMode.badge")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSessionHeaderIndicators = () => (
|
||||
<div class="flex items-center flex-wrap justify-center gap-2">
|
||||
{renderYoloModePill()}
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleCommandPaletteClick = () => {
|
||||
showCommandPalette(props.instance.id)
|
||||
}
|
||||
@@ -622,12 +700,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center min-w-0">
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
{renderSessionHeaderIndicators()}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||
@@ -719,12 +792,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="ml-auto flex items-center session-header-hints">
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
{renderSessionHeaderIndicators()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,104 +48,103 @@ interface SessionSidebarProps {
|
||||
}
|
||||
|
||||
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||
title={props.t("sessionList.actions.newSession.title")}
|
||||
onClick={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlusSquare class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||
title={props.t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={props.showSearch()}
|
||||
onClick={props.onToggleSearch}
|
||||
sx={{
|
||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Search class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => props.onSelectSession("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||
title={props.t("sessionList.actions.newSession.title")}
|
||||
onClick={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<PlusSquare class="w-5 h-5" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={props.drawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={props.onCloseLeftDrawer}
|
||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||
title={props.t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={props.showSearch()}
|
||||
onClick={props.onToggleSearch}
|
||||
sx={{
|
||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
<Search class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => props.onSelectSession("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||
>
|
||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={props.drawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={props.onCloseLeftDrawer}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={props.keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={props.keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instanceId}
|
||||
threads={props.threads()}
|
||||
activeSessionId={props.activeSessionId()}
|
||||
onSelect={props.onSelectSession}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={props.showSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instanceId}
|
||||
threads={props.threads()}
|
||||
activeSessionId={props.activeSessionId()}
|
||||
onSelect={props.onSelectSession}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={props.showSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
|
||||
|
||||
@@ -177,11 +176,10 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default SessionSidebar
|
||||
|
||||
@@ -89,6 +89,7 @@ interface RightPanelProps {
|
||||
const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
||||
"yolo-mode",
|
||||
"plan",
|
||||
"background-processes",
|
||||
"mcp",
|
||||
@@ -787,7 +788,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setRightPanelTab("changes")
|
||||
}
|
||||
|
||||
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||
const statusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||
|
||||
createEffect(() => {
|
||||
const currentExpanded = new Set(rightPanelExpandedItems())
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, Show, type Accessor, type Component } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import { Accordion } from "@kobalte/core"
|
||||
import { Tooltip } from "@kobalte/core/tooltip"
|
||||
import Switch from "@suid/material/Switch"
|
||||
|
||||
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
|
||||
@@ -12,6 +13,7 @@ import type { Session } from "../../../../../types/session"
|
||||
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
||||
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
||||
import InstanceServiceStatus from "../../../../instance-service-status"
|
||||
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
|
||||
|
||||
interface StatusTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
@@ -39,6 +41,35 @@ interface StatusTabProps {
|
||||
const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
||||
|
||||
const renderYoloModeSection = () => {
|
||||
const session = props.activeSession()
|
||||
if (!session) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.yoloMode.noSessionSelected")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base bg-surface-secondary px-3 py-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-primary">{props.t("instanceShell.yoloMode.title")}</div>
|
||||
<p class="mt-1 text-xs text-secondary">{props.t("instanceShell.yoloMode.description")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPermissionAutoAcceptEnabled(props.instanceId, session.id)}
|
||||
color="warning"
|
||||
size="small"
|
||||
inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }}
|
||||
onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderStatusSessionChanges = () => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
@@ -204,6 +235,12 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
}
|
||||
|
||||
const statusSections = [
|
||||
{
|
||||
id: "yolo-mode",
|
||||
labelKey: "instanceShell.rightPanel.sections.yoloMode",
|
||||
tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
|
||||
render: renderYoloModeSection,
|
||||
},
|
||||
{
|
||||
id: "session-changes",
|
||||
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||
@@ -281,29 +318,23 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
<For each={statusSections}>
|
||||
{(section) => (
|
||||
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||
<Accordion.Header>
|
||||
<Accordion.Header class="right-panel-accordion-header-row">
|
||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||
<span class="section-left">
|
||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||
<Tooltip.Trigger
|
||||
class="section-info-trigger"
|
||||
aria-label={props.t(section.tooltipKey)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info class="section-info-icon" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="section-info-tooltip">
|
||||
{props.t(section.tooltipKey)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip>
|
||||
<span class="section-label">{props.t(section.labelKey)}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
|
||||
<Info class="section-info-icon" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
@@ -123,7 +123,11 @@ export function Markdown(props: MarkdownProps) {
|
||||
version: () => resolved().version,
|
||||
})
|
||||
|
||||
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
||||
const commitCacheEntry = (
|
||||
snapshot: ReturnType<typeof resolved>,
|
||||
renderedHtml: string,
|
||||
options?: { cache?: boolean },
|
||||
) => {
|
||||
const cacheEntry: RenderCache = {
|
||||
text: snapshot.text,
|
||||
html: renderedHtml,
|
||||
@@ -131,7 +135,9 @@ export function Markdown(props: MarkdownProps) {
|
||||
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
|
||||
}
|
||||
setHtml(renderedHtml)
|
||||
cacheHandle.set(cacheEntry)
|
||||
if (options?.cache ?? true) {
|
||||
cacheHandle.set(cacheEntry)
|
||||
}
|
||||
notifyRendered()
|
||||
}
|
||||
|
||||
@@ -142,9 +148,10 @@ export function Markdown(props: MarkdownProps) {
|
||||
suppressHighlight: !snapshot.highlightEnabled,
|
||||
escapeRawHtml: snapshot.escapeRawHtml,
|
||||
})
|
||||
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
|
||||
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
commitCacheEntry(snapshot, rendered)
|
||||
commitCacheEntry(snapshot, rendered, { cache: shouldCache })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
import { selectInstanceTab } from "../stores/app-tabs"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -130,7 +130,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
|
||||
}
|
||||
|
||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||
setActiveInstanceId(location.instanceId)
|
||||
selectInstanceTab(location.instanceId)
|
||||
const parentToActivate = location.parentId ?? location.sessionId
|
||||
setActiveParentSession(location.instanceId, parentToActivate)
|
||||
if (location.parentId) {
|
||||
|
||||
@@ -129,6 +129,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
return map
|
||||
})
|
||||
|
||||
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
|
||||
|
||||
const lastCompactionIndex = createMemo(() => {
|
||||
// Depend on a single session revision signal (not every message/part read)
|
||||
// to keep reactive overhead small.
|
||||
@@ -315,15 +317,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
}
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
for (let index = ids.length - 1; index >= 0; index--) {
|
||||
const record = resolvedStore.getMessage(ids[index])
|
||||
if (record?.role === "assistant") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
const messageId = lastAssistantMessageId()
|
||||
if (!messageId) return -1
|
||||
return messageIndexById().get(messageId) ?? -1
|
||||
})
|
||||
|
||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||
@@ -734,88 +730,93 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const loading = Boolean(props.loading)
|
||||
const ids = messageIds()
|
||||
|
||||
if (loading) {
|
||||
handleClearTimelineSelection()
|
||||
previousTimelineIds = []
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
pendingTimelineMessagePartUpdates.clear()
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length < previousTimelineIds.length) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === previousTimelineIds.length) {
|
||||
let changedIndex = -1
|
||||
let changeCount = 0
|
||||
for (let index = 0; index < ids.length; index++) {
|
||||
if (ids[index] !== previousTimelineIds[index]) {
|
||||
changedIndex = index
|
||||
changeCount += 1
|
||||
if (changeCount > 1) break
|
||||
// Wrap all iteration of the store-proxied `ids` array in untrack()
|
||||
// to prevent O(n) per-element reactive subscriptions. The effect
|
||||
// only needs to re-run when `messageIds` (memo) changes.
|
||||
untrack(() => {
|
||||
if (loading) {
|
||||
handleClearTimelineSelection()
|
||||
previousTimelineIds = []
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
pendingTimelineMessagePartUpdates.clear()
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (changeCount === 1 && changedIndex >= 0) {
|
||||
const oldId = previousTimelineIds[changedIndex]
|
||||
const newId = ids[changedIndex]
|
||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||
seenTimelineMessageIds.delete(oldId)
|
||||
seenTimelineMessageIds.add(newId)
|
||||
setTimelineSegments((prev) => {
|
||||
const next = prev.map((segment) => {
|
||||
if (segment.messageId !== oldId) return segment
|
||||
const updatedId = segment.id.replace(oldId, newId)
|
||||
return { ...segment, messageId: newId, id: updatedId }
|
||||
})
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
|
||||
// Keep part count tracking in sync with id replacement.
|
||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||
if (existingPartCount !== undefined) {
|
||||
timelinePartCountsByMessageId.delete(oldId)
|
||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = [...ids]
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length < previousTimelineIds.length) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = [...ids]
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === previousTimelineIds.length) {
|
||||
let changedIndex = -1
|
||||
let changeCount = 0
|
||||
for (let index = 0; index < ids.length; index++) {
|
||||
if (ids[index] !== previousTimelineIds[index]) {
|
||||
changedIndex = index
|
||||
changeCount += 1
|
||||
if (changeCount > 1) break
|
||||
}
|
||||
}
|
||||
if (changeCount === 1 && changedIndex >= 0) {
|
||||
const oldId = previousTimelineIds[changedIndex]
|
||||
const newId = ids[changedIndex]
|
||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||
seenTimelineMessageIds.delete(oldId)
|
||||
seenTimelineMessageIds.add(newId)
|
||||
setTimelineSegments((prev) => {
|
||||
const next = prev.map((segment) => {
|
||||
if (segment.messageId !== oldId) return segment
|
||||
const updatedId = segment.id.replace(oldId, newId)
|
||||
return { ...segment, messageId: newId, id: updatedId }
|
||||
})
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
// Keep part count tracking in sync with id replacement.
|
||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||
if (existingPartCount !== undefined) {
|
||||
timelinePartCountsByMessageId.delete(oldId)
|
||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||
}
|
||||
|
||||
previousTimelineIds = [...ids]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newIds: string[] = []
|
||||
ids.forEach((id) => {
|
||||
if (!seenTimelineMessageIds.has(id)) {
|
||||
newIds.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
if (newIds.length > 0) {
|
||||
newIds.forEach((id) => {
|
||||
seenTimelineMessageIds.add(id)
|
||||
appendTimelineForMessage(id)
|
||||
const newIds: string[] = []
|
||||
ids.forEach((id) => {
|
||||
if (!seenTimelineMessageIds.has(id)) {
|
||||
newIds.push(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
if (newIds.length > 0) {
|
||||
newIds.forEach((id) => {
|
||||
seenTimelineMessageIds.add(id)
|
||||
appendTimelineForMessage(id)
|
||||
})
|
||||
}
|
||||
|
||||
previousTimelineIds = [...ids]
|
||||
})
|
||||
})
|
||||
|
||||
function clearPendingTimelinePartUpdateFrame() {
|
||||
@@ -886,36 +887,49 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
createEffect(() => {
|
||||
if (props.loading) return
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
// Also re-run when sessionRevision bumps (covers part additions within
|
||||
// existing messages) but read individual records inside untrack() to
|
||||
// avoid creating O(n) fine-grained subscriptions.
|
||||
sessionRevision()
|
||||
|
||||
let hasChanges = false
|
||||
for (const messageId of ids) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
const partCount = record?.partIds.length ?? 0
|
||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||
// Wrap the iteration in untrack() so that accessing individual elements
|
||||
// of the store-proxied `ids` array does not create O(n) per-element
|
||||
// reactive subscriptions. We only need to re-run when the memo
|
||||
// (messageIds) or sessionRevision changes — not per-element.
|
||||
untrack(() => {
|
||||
const resolvedStore = store()
|
||||
const idsSet = new Set(ids)
|
||||
let hasChanges = false
|
||||
|
||||
if (previousCount === undefined) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
continue
|
||||
for (const messageId of ids) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
const partCount = record?.partIds.length ?? 0
|
||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||
|
||||
if (previousCount === undefined) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
continue
|
||||
}
|
||||
|
||||
if (previousCount !== partCount) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
pendingTimelineMessagePartUpdates.add(messageId)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
if (previousCount !== partCount) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
pendingTimelineMessagePartUpdates.add(messageId)
|
||||
hasChanges = true
|
||||
// Drop tracking for ids that are no longer present.
|
||||
// Use the Set for O(1) lookups instead of ids.includes() which is O(n).
|
||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||
if (!idsSet.has(trackedId)) {
|
||||
timelinePartCountsByMessageId.delete(trackedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop tracking for ids that are no longer present.
|
||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||
if (!ids.includes(trackedId)) {
|
||||
timelinePartCountsByMessageId.delete(trackedId)
|
||||
if (hasChanges) {
|
||||
scheduleTimelinePartUpdateFlush()
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
scheduleTimelinePartUpdateFlush()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -19,7 +19,12 @@ import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
||||
import { canUseConversationMode, isConversationModeEnabled, toggleConversationMode } from "../stores/conversation-speech"
|
||||
import {
|
||||
canUseConversationMode,
|
||||
clearConversationPlaybackForInstance,
|
||||
isConversationModeEnabled,
|
||||
toggleConversationMode,
|
||||
} from "../stores/conversation-speech"
|
||||
const log = getLogger("actions")
|
||||
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
|
||||
|
||||
@@ -492,6 +497,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
|
||||
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
|
||||
voiceButtonPressed = true
|
||||
// Treat a mic press as barge-in: stop any active assistant speech before listening.
|
||||
clearConversationPlaybackForInstance(props.instanceId)
|
||||
|
||||
if (event instanceof PointerEvent) {
|
||||
const target = event.currentTarget
|
||||
@@ -533,6 +540,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
mode={pickerMode()}
|
||||
onClose={handlePickerClose}
|
||||
onSelect={handlePickerSelect}
|
||||
onSubmitWithoutSelection={() => {
|
||||
handlePickerClose()
|
||||
void handleSend()
|
||||
}}
|
||||
agents={instanceAgents()}
|
||||
commands={getCommands(props.instanceId)}
|
||||
instanceClient={instance()!.client}
|
||||
|
||||
@@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
const pos = atPosition()
|
||||
if (pickerMode() === "mention" && pos !== null) {
|
||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||
|
||||
// Remove the partial @mention text from the textarea when ESC is pressed
|
||||
const textarea = options.getTextarea()
|
||||
if (textarea) {
|
||||
const currentPrompt = options.prompt()
|
||||
const cursorPos = textarea.selectionStart
|
||||
// Remove text from @ position to cursor position
|
||||
const before = currentPrompt.substring(0, pos)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
options.setPrompt(before + after)
|
||||
|
||||
// Restore cursor position to where @ was
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
nextTextarea.setSelectionRange(pos, pos)
|
||||
}
|
||||
}, 0)
|
||||
|
||||
// Clear ignoredAtPositions so typing @ again will work
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
}
|
||||
}
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Switch } from "@kobalte/core/switch"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { toDataURL } from "qrcode"
|
||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { restartCli } from "../lib/native/cli"
|
||||
@@ -10,6 +10,7 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
@@ -32,17 +33,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||
const list = addresses()
|
||||
if (!allowExternalConnections()) {
|
||||
return []
|
||||
return { recommended: null, hidden: [] }
|
||||
}
|
||||
// Local URL is displayed separately; list only remote-friendly addresses.
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
return splitRemoteAddresses(list)
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
@@ -53,6 +54,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||
setMeta(metaResult)
|
||||
setAuthStatus(authResult)
|
||||
setShowAllAddresses(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -326,7 +328,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
|
||||
<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={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<div class="remote-address-list">
|
||||
<Show when={meta()?.localUrl}>
|
||||
{(url) => {
|
||||
@@ -373,8 +375,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
<Show when={displayAddresses().recommended}>
|
||||
{(addressAccessor) => {
|
||||
const address = addressAccessor()
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
@@ -384,13 +387,14 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
: 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}
|
||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
@@ -425,7 +429,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={displayAddresses().hidden.length > 0}>
|
||||
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||
<button
|
||||
class="remote-address-disclosure-trigger"
|
||||
type="button"
|
||||
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||
aria-expanded={showAllAddresses()}
|
||||
>
|
||||
<span class="remote-address-disclosure-label">
|
||||
{showAllAddresses()
|
||||
? t("remoteAccess.addresses.actions.hideOther")
|
||||
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||
</span>
|
||||
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||
</button>
|
||||
|
||||
<Show when={showAllAddresses()}>
|
||||
<div class="remote-address-disclosure-content">
|
||||
<For each={displayAddresses().hidden}>
|
||||
{(address) => {
|
||||
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>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
||||
import type { SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
@@ -55,6 +55,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
|
||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
onCleanup(() => window.clearInterval(timer))
|
||||
})
|
||||
|
||||
const normalizeSessionLabel = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
@@ -400,7 +407,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||
const title = () => session()?.title || t("sessionList.session.untitled")
|
||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||
const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId)
|
||||
const statusLabel = () => {
|
||||
const retryState = retry()
|
||||
if (retryState) {
|
||||
const seconds = getRetrySeconds(retryState.next, now())
|
||||
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
|
||||
}
|
||||
switch (formatSessionStatus(status())) {
|
||||
case "working":
|
||||
return t("sessionList.status.working")
|
||||
@@ -413,13 +426,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||
const needsInput = () => needsPermission() || needsQuestion()
|
||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`)
|
||||
const statusText = () =>
|
||||
needsPermission()
|
||||
? t("sessionList.status.needsPermission")
|
||||
: needsQuestion()
|
||||
? t("sessionList.status.needsInput")
|
||||
: statusLabel()
|
||||
const statusTooltip = () => {
|
||||
const retryState = retry()
|
||||
if (!retryState) return undefined
|
||||
return t("sessionList.status.retryTooltip", {
|
||||
message: retryState.message,
|
||||
attempt: String(retryState.attempt),
|
||||
})
|
||||
}
|
||||
|
||||
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
|
||||
|
||||
@@ -499,7 +520,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||
</span>
|
||||
</Show>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
|
||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{statusText()}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
|
||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid"
|
||||
import { createMemo, For, type Component } from "solid-js"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import { NotificationsSettingsSection } from "./settings/notifications-settings-
|
||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
||||
|
||||
export const SettingsScreen: Component = () => {
|
||||
const { t } = useI18n()
|
||||
@@ -23,6 +24,7 @@ export const SettingsScreen: Component = () => {
|
||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||
])
|
||||
|
||||
@@ -34,6 +36,8 @@ export const SettingsScreen: Component = () => {
|
||||
return <RemoteAccessSettingsSection />
|
||||
case "speech":
|
||||
return <SpeechSettingsSection />
|
||||
case "sidecars":
|
||||
return <SideCarsSettingsSection />
|
||||
case "opencode":
|
||||
return <OpenCodeSettingsSection />
|
||||
case "appearance":
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { createEffect, createSignal, type Component } from "solid-js"
|
||||
import { Terminal } from "lucide-solid"
|
||||
import { Select } from "@kobalte/core/select"
|
||||
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||
import { ChevronDown, Terminal } from "lucide-solid"
|
||||
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
||||
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
import type { ServerLogLevel } from "../../stores/preferences"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
type LogLevelOption = {
|
||||
value: ServerLogLevel
|
||||
label: string
|
||||
}
|
||||
|
||||
export const OpenCodeSettingsSection: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const { serverSettings, updateLastUsedBinary } = useConfig()
|
||||
const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig()
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||
const logLevelOptions = createMemo<LogLevelOption[]>(() => [
|
||||
{ value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") },
|
||||
{ value: "INFO", label: t("settings.opencode.logLevel.option.info") },
|
||||
{ value: "WARN", label: t("settings.opencode.logLevel.option.warn") },
|
||||
{ value: "ERROR", label: t("settings.opencode.logLevel.option.error") },
|
||||
])
|
||||
const selectedLogLevel = createMemo(
|
||||
() => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0],
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const binary = serverSettings().opencodeBinary || "opencode"
|
||||
@@ -37,6 +53,60 @@ export const OpenCodeSettingsSection: Component = () => {
|
||||
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("settings.opencode.logLevel.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("settings.opencode.logLevel.subtitle")}</p>
|
||||
</div>
|
||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||
</div>
|
||||
<div class="settings-card-body">
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("settings.opencode.logLevel.selector.title")}</div>
|
||||
<div class="settings-toggle-caption">{t("settings.opencode.logLevel.selector.subtitle")}</div>
|
||||
</div>
|
||||
<Select<LogLevelOption>
|
||||
value={selectedLogLevel()}
|
||||
onChange={(option) => {
|
||||
if (!option) return
|
||||
updateLogLevel(option.value)
|
||||
}}
|
||||
options={logLevelOptions()}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
itemComponent={(itemProps) => (
|
||||
<Select.Item item={itemProps.item} class="selector-option">
|
||||
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||
</Select.Item>
|
||||
)}
|
||||
>
|
||||
<Select.Trigger class="selector-trigger" aria-label={t("settings.opencode.logLevel.title")}>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Select.Value<LogLevelOption>>
|
||||
{(state) => (
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
{state.selectedOption()?.label}
|
||||
</span>
|
||||
)}
|
||||
</Select.Value>
|
||||
</div>
|
||||
<Select.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content class="selector-popover">
|
||||
<Select.Listbox class="selector-listbox" />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Switch } from "@kobalte/core/switch"
|
||||
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
||||
import { toDataURL } from "qrcode"
|
||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { restartCli } from "../../lib/native/cli"
|
||||
@@ -9,6 +9,7 @@ import { serverSettings, setListeningMode } from "../../stores/preferences"
|
||||
import { showConfirmDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -30,14 +31,15 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||
const list = addresses()
|
||||
if (!allowExternalConnections()) return []
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
if (!allowExternalConnections()) return { recommended: null, hidden: [] }
|
||||
return splitRemoteAddresses(list)
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
@@ -48,6 +50,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||
setMeta(metaResult)
|
||||
setAuthStatus(authResult)
|
||||
setShowAllAddresses(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -218,31 +221,35 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||
>
|
||||
<div class="settings-card-content">
|
||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||
<p class="settings-help-text">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.status.set")
|
||||
: t("remoteAccess.password.status.unset")}
|
||||
</p>
|
||||
<div class="settings-password-summary-row">
|
||||
<div class="settings-password-summary-copy">
|
||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||
<p class="settings-help-text">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.status.set")
|
||||
: t("remoteAccess.password.status.unset")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-password-actions">
|
||||
<button
|
||||
class="settings-pill-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPasswordFormOpen(!passwordFormOpen())
|
||||
setPasswordError(null)
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? t("remoteAccess.password.actions.cancel")
|
||||
: authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.actions.change")
|
||||
: t("remoteAccess.password.actions.set")}
|
||||
</button>
|
||||
<div class="settings-password-actions">
|
||||
<button
|
||||
class="settings-pill-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPasswordFormOpen(!passwordFormOpen())
|
||||
setPasswordError(null)
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? t("remoteAccess.password.actions.cancel")
|
||||
: authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.actions.change")
|
||||
: t("remoteAccess.password.actions.set")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={passwordFormOpen()}>
|
||||
<Show when={passwordFormOpen()}>
|
||||
<div class="settings-form-group">
|
||||
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
||||
<input
|
||||
@@ -292,7 +299,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
<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={displayAddresses().length > 0 || meta()?.localUrl}
|
||||
when={Boolean(displayAddresses().recommended) || meta()?.localUrl}
|
||||
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
||||
>
|
||||
<div class="remote-address-list">
|
||||
@@ -342,8 +349,9 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
<Show when={displayAddresses().recommended}>
|
||||
{(addressAccessor) => {
|
||||
const address = addressAccessor()
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
@@ -383,7 +391,11 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
<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" />
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -391,7 +403,80 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={displayAddresses().hidden.length > 0}>
|
||||
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||
<button
|
||||
class="remote-address-disclosure-trigger"
|
||||
type="button"
|
||||
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||
aria-expanded={showAllAddresses()}
|
||||
>
|
||||
<span class="remote-address-disclosure-label">
|
||||
{showAllAddresses()
|
||||
? t("remoteAccess.addresses.actions.hideOther")
|
||||
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||
</span>
|
||||
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||
</button>
|
||||
|
||||
<Show when={showAllAddresses()}>
|
||||
<div class="remote-address-disclosure-content">
|
||||
<For each={displayAddresses().hidden}>
|
||||
{(address) => {
|
||||
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>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { createMemo, createSignal, For, Show, onMount, type Component } from "solid-js"
|
||||
import { Globe, Loader2, Plus, Trash2 } from "lucide-solid"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../../stores/sidecars"
|
||||
|
||||
function deriveSidecarId(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
}
|
||||
|
||||
export const SideCarsSettingsSection: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const [name, setName] = createSignal("")
|
||||
const [port, setPort] = createSignal("3000")
|
||||
const [insecure, setInsecure] = createSignal(false)
|
||||
const [prefixMode, setPrefixMode] = createSignal<"strip" | "preserve">("strip")
|
||||
const [busyId, setBusyId] = createSignal<string | null>(null)
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [formError, setFormError] = createSignal<string | null>(null)
|
||||
const [actionError, setActionError] = createSignal<string | null>(null)
|
||||
|
||||
onMount(() => {
|
||||
void ensureSidecarsLoaded()
|
||||
})
|
||||
|
||||
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||
const derivedId = createMemo(() => deriveSidecarId(name()) || "your-sidecar")
|
||||
|
||||
async function handleCreate() {
|
||||
const trimmedName = name().trim()
|
||||
const nextPort = Number(port())
|
||||
if (!trimmedName || !Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) {
|
||||
setFormError(t("sidecars.form.validation"))
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
await serverApi.createSidecar({
|
||||
kind: "port",
|
||||
name: trimmedName,
|
||||
port: nextPort,
|
||||
insecure: insecure(),
|
||||
prefixMode: prefixMode(),
|
||||
})
|
||||
setName("")
|
||||
setPort("3000")
|
||||
setInsecure(false)
|
||||
setPrefixMode("strip")
|
||||
} catch (error) {
|
||||
setFormError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setBusyId(id)
|
||||
setActionError(null)
|
||||
try {
|
||||
await serverApi.deleteSidecar(id)
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="settings-section-stack">
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div class="settings-card-heading-with-icon">
|
||||
<Globe class="settings-card-heading-icon" />
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("settings.section.sidecars.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("settings.section.sidecars.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-card-content">
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("sidecars.form.name")}</div>
|
||||
<div class="settings-toggle-caption">{t("sidecars.basePath")}: <code>/sidecars/{derivedId()}</code></div>
|
||||
</div>
|
||||
<input
|
||||
class="selector-input w-full max-w-xs"
|
||||
value={name()}
|
||||
onInput={(event) => {
|
||||
setFormError(null)
|
||||
setName(event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("sidecars.form.port")}</div>
|
||||
<div class="settings-toggle-caption">127.0.0.1</div>
|
||||
</div>
|
||||
<input
|
||||
class="selector-input w-full max-w-xs"
|
||||
value={port()}
|
||||
onInput={(event) => {
|
||||
setFormError(null)
|
||||
setPort(event.currentTarget.value)
|
||||
}}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("sidecars.form.protocol")}</div>
|
||||
<div class="settings-toggle-caption">{t("sidecars.form.protocol.help")}</div>
|
||||
</div>
|
||||
<select class="selector-input w-full max-w-xs" value={insecure() ? "http" : "https"} onChange={(event) => setInsecure(event.currentTarget.value === "http") }>
|
||||
<option value="https">{t("sidecars.form.protocol.https")}</option>
|
||||
<option value="http">{t("sidecars.form.protocol.http")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("sidecars.form.prefixMode")}</div>
|
||||
<div class="settings-toggle-caption">{t("sidecars.form.prefixMode.help")}</div>
|
||||
</div>
|
||||
<select class="selector-input w-full max-w-xs" value={prefixMode()} onChange={(event) => setPrefixMode(event.currentTarget.value as "strip" | "preserve") }>
|
||||
<option value="strip">{t("sidecars.form.prefixMode.strip")}</option>
|
||||
<option value="preserve">{t("sidecars.form.prefixMode.preserve")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Show when={formError()}>
|
||||
<div class="text-sm text-red-500">{formError()}</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="selector-button selector-button-primary" disabled={creating()} onClick={() => void handleCreate()}>
|
||||
<Show when={creating()} fallback={<Plus class="w-4 h-4" />}>
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
</Show>
|
||||
<span>{t("sidecars.form.add")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("sidecars.settings.listTitle")}</h3>
|
||||
<p class="settings-card-subtitle">{t("sidecars.settings.listSubtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card-content">
|
||||
<Show when={actionError()}>
|
||||
<div class="text-sm text-red-500">{actionError()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!sidecarsLoading()} fallback={<div class="settings-card-message">{t("sidecars.picker.loading")}</div>}>
|
||||
<Show when={orderedSidecars().length > 0} fallback={<div class="settings-card-message">{t("sidecars.settings.empty")}</div>}>
|
||||
<For each={orderedSidecars()}>
|
||||
{(sidecar) => (
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{sidecar.name}</div>
|
||||
<div class="settings-toggle-caption">
|
||||
{t("sidecars.kind.port")} · {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
|
||||
</div>
|
||||
<div class="settings-toggle-caption">
|
||||
{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code> · {t(`sidecars.form.prefixMode.${sidecar.prefixMode}`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-secondary min-w-[4.5rem] text-right">{t(`sidecars.status.${sidecar.status}`)}</span>
|
||||
<button type="button" class="selector-button selector-button-secondary" disabled={busyId() === sidecar.id} onClick={() => void handleDelete(sidecar.id)}>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
packages/ui/src/components/sidecar-picker-dialog.tsx
Normal file
82
packages/ui/src/components/sidecar-picker-dialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { For, Show, createEffect, createMemo, type Component } from "solid-js"
|
||||
import { Globe, Square } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../stores/sidecars"
|
||||
|
||||
interface SideCarPickerDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onOpenSidecar: (sidecarId: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export const SideCarPickerDialog: Component<SideCarPickerDialogProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
void ensureSidecarsLoaded()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-2xl p-6 flex flex-col gap-4 max-h-[80vh] overflow-hidden">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("sidecars.picker.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
{t("sidecars.picker.subtitle")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto flex flex-col gap-3">
|
||||
<Show when={!sidecarsLoading()} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.loading")}</div>}>
|
||||
<Show when={orderedSidecars().length > 0} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.empty")}</div>}>
|
||||
<For each={orderedSidecars()}>
|
||||
{(sidecar) => (
|
||||
<button
|
||||
type="button"
|
||||
class="panel-list-item panel-list-item-content text-left disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={sidecar.status !== "running"}
|
||||
onClick={() => void props.onOpenSidecar(sidecar.id)}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 w-full">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="panel-empty-state-icon !w-10 !h-10">
|
||||
<Globe class="w-5 h-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-primary truncate">{sidecar.name}</div>
|
||||
<div class="text-xs text-muted">
|
||||
{t("sidecars.kind.port")} - {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
|
||||
</div>
|
||||
<div class="text-xs text-muted mt-1">{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-secondary flex items-center gap-2">
|
||||
<Square class="w-4 h-4" />
|
||||
<span>{t(`sidecars.status.${sidecar.status}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||
{t("sidecars.picker.close")}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
197
packages/ui/src/components/sidecar-view.tsx
Normal file
197
packages/ui/src/components/sidecar-view.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-solid"
|
||||
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||
import type { SideCarTabRecord } from "../stores/sidecars"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface SideCarViewProps {
|
||||
tab: SideCarTabRecord
|
||||
}
|
||||
|
||||
export const SideCarView: Component<SideCarViewProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [frameSrc, setFrameSrc] = createSignal(props.tab.shellUrl)
|
||||
const [pathInput, setPathInput] = createSignal("/")
|
||||
let iframeRef: HTMLIFrameElement | undefined
|
||||
|
||||
const lockedBaseLabel = createMemo(() => {
|
||||
const hostLabel = props.tab.port ? `${props.tab.name}:${props.tab.port}` : props.tab.name
|
||||
if (props.tab.prefixMode === "preserve") {
|
||||
return `${hostLabel}${props.tab.proxyBasePath}`
|
||||
}
|
||||
return hostLabel
|
||||
})
|
||||
|
||||
const getEditablePathFromUrl = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
const basePath = props.tab.proxyBasePath
|
||||
let pathname = parsed.pathname
|
||||
|
||||
if (basePath && pathname.startsWith(basePath)) {
|
||||
pathname = pathname.slice(basePath.length) || "/"
|
||||
}
|
||||
|
||||
if (!pathname.startsWith("/")) {
|
||||
pathname = `/${pathname}`
|
||||
}
|
||||
|
||||
return `${pathname}${parsed.search}${parsed.hash}`
|
||||
} catch {
|
||||
return "/"
|
||||
}
|
||||
}
|
||||
|
||||
const buildNormalizedTargetUrl = (rawInput: string): string => {
|
||||
const trimmed = rawInput.trim()
|
||||
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`
|
||||
const parsed = new URL(withLeadingSlash || "/", window.location.origin)
|
||||
|
||||
const safeSegments: string[] = []
|
||||
for (const segment of parsed.pathname.split("/")) {
|
||||
if (!segment || segment === ".") {
|
||||
continue
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (safeSegments.length > 0) {
|
||||
safeSegments.pop()
|
||||
}
|
||||
continue
|
||||
}
|
||||
safeSegments.push(segment)
|
||||
}
|
||||
|
||||
const normalizedPath = `/${safeSegments.join("/")}` || "/"
|
||||
const basePath = props.tab.proxyBasePath
|
||||
return `${basePath}${normalizedPath}${parsed.search}${parsed.hash}`
|
||||
}
|
||||
|
||||
const syncPathInputFromFrame = () => {
|
||||
try {
|
||||
const currentHref = iframeRef?.contentWindow?.location.href
|
||||
if (!currentHref) {
|
||||
return
|
||||
}
|
||||
setPathInput(getEditablePathFromUrl(currentHref))
|
||||
} catch {
|
||||
setPathInput(getEditablePathFromUrl(frameSrc()))
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
setFrameSrc(props.tab.shellUrl)
|
||||
setPathInput(getEditablePathFromUrl(props.tab.shellUrl))
|
||||
})
|
||||
|
||||
const handleBack = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
try {
|
||||
const frameWindow = iframeRef?.contentWindow
|
||||
if (!frameWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (frameWindow.history.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
frameWindow.focus()
|
||||
frameWindow.history.go(-1)
|
||||
} catch {
|
||||
// Ignore navigation errors from pages that do not expose history access.
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
try {
|
||||
iframeRef?.contentWindow?.location.reload()
|
||||
return
|
||||
} catch {
|
||||
// Fall back to resetting the iframe source if the frame cannot be reloaded directly.
|
||||
}
|
||||
|
||||
setFrameSrc("about:blank")
|
||||
requestAnimationFrame(() => setFrameSrc(props.tab.shellUrl))
|
||||
}
|
||||
|
||||
const handleGo = (event?: Event) => {
|
||||
event?.preventDefault()
|
||||
|
||||
const nextUrl = buildNormalizedTargetUrl(pathInput())
|
||||
setFrameSrc(nextUrl)
|
||||
setPathInput(getEditablePathFromUrl(nextUrl))
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 px-3 py-2"
|
||||
style={{ "border-bottom": "1px solid var(--border-base)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="new-tab-button"
|
||||
onClick={handleBack}
|
||||
title={t("sidecars.back")}
|
||||
aria-label={t("sidecars.back")}
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="new-tab-button"
|
||||
onClick={handleRefresh}
|
||||
title={t("sidecars.refresh")}
|
||||
aria-label={t("sidecars.refresh")}
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
class="shrink-0 rounded-md px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
background: "var(--surface-secondary)",
|
||||
color: "var(--text-secondary)",
|
||||
border: "1px solid var(--border-base)",
|
||||
}}
|
||||
>
|
||||
{lockedBaseLabel()}
|
||||
</div>
|
||||
<form class="flex min-w-0 flex-1 items-center gap-2" onSubmit={(event) => handleGo(event)}>
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm outline-none"
|
||||
style={{
|
||||
background: "var(--surface-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
border: "1px solid var(--border-base)",
|
||||
}}
|
||||
value={pathInput()}
|
||||
onInput={(event) => setPathInput(event.currentTarget.value)}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
aria-label={t("sidecars.path")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="new-tab-button"
|
||||
title={t("sidecars.go")}
|
||||
aria-label={t("sidecars.go")}
|
||||
>
|
||||
<ArrowRight class="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={frameSrc()}
|
||||
title={props.tab.name}
|
||||
class="min-h-0 flex-1 w-full border-0 bg-surface"
|
||||
referrerPolicy="same-origin"
|
||||
onLoad={syncPathInputFromFrame}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
||||
import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
||||
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 { DiffViewMode } from "../../stores/preferences"
|
||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||
import { getRelativePath } from "./utils"
|
||||
import { getCacheEntry } from "../../lib/global-cache"
|
||||
import { copyToClipboard } from "../../lib/clipboard"
|
||||
|
||||
const LazyToolCallDiffViewer = lazy(() =>
|
||||
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
||||
@@ -43,6 +46,16 @@ export function createDiffContentRenderer(params: {
|
||||
handleScrollRendered: () => 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) => {
|
||||
params.scrollHelpers.registerContainer(element)
|
||||
}
|
||||
@@ -58,7 +71,12 @@ export function createDiffContentRenderer(params: {
|
||||
: params.t("toolCall.diff.label"))
|
||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||
const preferredMode = () => (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 state = params.toolState()
|
||||
const disableScrollTracking = Boolean(
|
||||
@@ -76,17 +94,40 @@ export function createDiffContentRenderer(params: {
|
||||
}
|
||||
})()
|
||||
|
||||
let cachedHtml: string | undefined
|
||||
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
||||
const currentMode = diffMode()
|
||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
const currentMode = createMemo(() => effectiveMode())
|
||||
const currentWrap = createMemo(() => shouldWrap())
|
||||
const cachedHtml = createMemo(() => {
|
||||
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
||||
if (
|
||||
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 viewModeTitle = () =>
|
||||
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 = () => {
|
||||
if (!disableScrollTracking) {
|
||||
params.handleScrollRendered()
|
||||
@@ -95,41 +136,54 @@ export function createDiffContentRenderer(params: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
ref={registerRef}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
data-diff-mode={currentMode()}
|
||||
ref={registerRef}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<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>
|
||||
<div class="tool-call-diff-toggle">
|
||||
<div class="file-viewer-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "split"}
|
||||
onClick={() => handleModeChange("split")}
|
||||
class="file-viewer-toolbar-icon-button"
|
||||
onClick={() => void copyToClipboard(payload.diffText)}
|
||||
aria-label={copyPatchTitle()}
|
||||
title={copyPatchTitle()}
|
||||
>
|
||||
{params.t("toolCall.diff.viewMode.split")}
|
||||
<Copy class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "unified"}
|
||||
onClick={() => handleModeChange("unified")}
|
||||
class="file-viewer-toolbar-icon-button"
|
||||
onClick={() => handleModeChange(nextViewMode())}
|
||||
aria-label={viewModeTitle()}
|
||||
title={viewModeTitle()}
|
||||
>
|
||||
{params.t("toolCall.diff.viewMode.unified")}
|
||||
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{cachedHtml ? (
|
||||
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
|
||||
{cachedHtml() ? (
|
||||
<CachedDiffMarkup html={cachedHtml()!} onRendered={handleDiffRendered} />
|
||||
) : (
|
||||
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
||||
<LazyToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
mode={currentMode()}
|
||||
wrap={currentWrap()}
|
||||
cacheEntryParams={cacheEntryParams as any}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
|
||||
@@ -79,6 +79,7 @@ interface UnifiedPickerProps {
|
||||
mode?: "mention" | "command"
|
||||
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||
onClose: () => void
|
||||
onSubmitWithoutSelection?: () => void
|
||||
agents: Agent[]
|
||||
commands?: SDKCommand[]
|
||||
instanceClient: OpencodeClient | null
|
||||
@@ -404,6 +405,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
if (selected) {
|
||||
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
||||
props.onSelect(selected, action)
|
||||
} else if (e.key === "Enter" && mode() === "mention") {
|
||||
props.onSubmitWithoutSelection?.()
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -10,7 +10,10 @@ import type {
|
||||
SpeechCapabilitiesResponse,
|
||||
SpeechSynthesisResponse,
|
||||
SpeechTranscriptionResponse,
|
||||
SideCar,
|
||||
ServerMeta,
|
||||
RemoteServerProbeRequest,
|
||||
RemoteServerProbeResponse,
|
||||
VoiceModeStateResponse,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceDescriptor,
|
||||
@@ -191,9 +194,42 @@ export const serverApi = {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchSidecars(): Promise<{ sidecars: SideCar[] }> {
|
||||
return request<{ sidecars: SideCar[] }>("/api/sidecars")
|
||||
},
|
||||
createSidecar(payload: {
|
||||
kind: "port"
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: "strip" | "preserve"
|
||||
}): Promise<SideCar> {
|
||||
return request<SideCar>("/api/sidecars", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
updateSidecar(
|
||||
id: string,
|
||||
payload: Partial<{ name: string; port: number; insecure: boolean; prefixMode: "strip" | "preserve" }>,
|
||||
): Promise<SideCar> {
|
||||
return request<SideCar>(`/api/sidecars/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
deleteSidecar(id: string): Promise<void> {
|
||||
return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
fetchServerMeta(): Promise<ServerMeta> {
|
||||
return request<ServerMeta>("/api/meta")
|
||||
},
|
||||
probeRemoteServer(payload: RemoteServerProbeRequest): Promise<RemoteServerProbeResponse> {
|
||||
return request<RemoteServerProbeResponse>("/api/remote-servers/probe", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||
},
|
||||
@@ -430,4 +466,4 @@ function buildClientEventsUrl(identity: { clientId: string; connectionId: string
|
||||
return `${url.pathname}${url.search}`
|
||||
}
|
||||
|
||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
|
||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, SideCar }
|
||||
|
||||
@@ -19,9 +19,6 @@ export function formatCompactCount(value: number): string {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 10_000) {
|
||||
return `${Math.round(value / 1_000)}K`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const label = `${(value / 1_000).toFixed(1)}K`
|
||||
return label.replace(/\.0K$/, "K")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const log = getLogger("actions")
|
||||
interface UseAppLifecycleOptions {
|
||||
setEscapeInDebounce: (value: boolean) => void
|
||||
handleNewInstanceRequest: () => void
|
||||
handleCloseActiveTab: () => Promise<void>
|
||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||
handleNewSession: (instanceId: string) => Promise<void>
|
||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||
@@ -31,7 +32,7 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
|
||||
setupTabKeyboardShortcuts(
|
||||
options.handleNewInstanceRequest,
|
||||
options.handleCloseInstance,
|
||||
options.handleCloseActiveTab,
|
||||
options.handleNewSession,
|
||||
options.handleCloseSession,
|
||||
() => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createSignal, onMount } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
|
||||
import { createCommandRegistry, type Command } from "../commands"
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
||||
import { activeInstanceId } from "../../stores/instances"
|
||||
import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs"
|
||||
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
@@ -41,6 +42,7 @@ export interface UseCommandsOptions {
|
||||
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
||||
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
||||
handleNewInstanceRequest: () => void
|
||||
handleCloseActiveTab: () => Promise<void>
|
||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||
handleNewSession: (instanceId: string) => Promise<void>
|
||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||
@@ -90,9 +92,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
||||
shortcut: { key: "W", meta: true },
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
await options.handleCloseInstance(instance.id)
|
||||
await options.handleCloseActiveTab()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -103,13 +103,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
category: "Instance",
|
||||
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
||||
shortcut: { key: "]", meta: true },
|
||||
action: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) setActiveInstanceId(ids[next])
|
||||
},
|
||||
action: () => selectNextAppTab(),
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
@@ -119,13 +113,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
category: "Instance",
|
||||
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
||||
shortcut: { key: "[", meta: true },
|
||||
action: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) setActiveInstanceId(ids[prev])
|
||||
},
|
||||
action: () => selectPreviousAppTab(),
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "Open folder picker to create new instance",
|
||||
"commands.newInstance.keywords": "folder, project, workspace",
|
||||
|
||||
"commands.closeInstance.label": "Close Instance",
|
||||
"commands.closeInstance.description": "Stop current instance's server",
|
||||
"commands.closeInstance.keywords": "stop, quit, close",
|
||||
"commands.closeInstance.label": "Close Tab",
|
||||
"commands.closeInstance.description": "Close the current top-level tab",
|
||||
"commands.closeInstance.keywords": "stop, quit, close, tab",
|
||||
|
||||
"commands.nextInstance.label": "Next Instance",
|
||||
"commands.nextInstance.description": "Cycle to next instance tab",
|
||||
"commands.nextInstance.keywords": "switch, navigate",
|
||||
"commands.nextInstance.label": "Next Tab",
|
||||
"commands.nextInstance.description": "Cycle to the next top-level tab",
|
||||
"commands.nextInstance.keywords": "switch, navigate, tab",
|
||||
|
||||
"commands.previousInstance.label": "Previous Instance",
|
||||
"commands.previousInstance.description": "Cycle to previous instance tab",
|
||||
"commands.previousInstance.keywords": "switch, navigate",
|
||||
"commands.previousInstance.label": "Previous Tab",
|
||||
"commands.previousInstance.description": "Cycle to the previous top-level tab",
|
||||
"commands.previousInstance.keywords": "switch, navigate, tab",
|
||||
|
||||
"commands.newSession.label": "New Session",
|
||||
"commands.newSession.description": "Create a new parent session",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.subtitle": "Select any folder on your computer",
|
||||
"folderSelection.browse.button": "Browse Folders",
|
||||
"folderSelection.browse.buttonOpening": "Opening...",
|
||||
"folderSelection.actions.title": "Open Folder or Connect Server",
|
||||
"folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server",
|
||||
"folderSelection.actions.connectButton": "Connect CodeNomad Server",
|
||||
|
||||
"folderSelection.advancedSettings": "Advanced Settings",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
||||
|
||||
"folderSelection.dialog.title": "Select Workspace",
|
||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||
|
||||
"folderSelection.tabs.local": "Local Folders",
|
||||
"folderSelection.tabs.servers": "Servers",
|
||||
"folderSelection.servers.title": "Saved Servers",
|
||||
"folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window",
|
||||
"folderSelection.servers.count": "{count} Servers",
|
||||
"folderSelection.servers.empty.title": "No Saved Servers",
|
||||
"folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device",
|
||||
"folderSelection.servers.connectTitle": "Connect to Server",
|
||||
"folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window",
|
||||
"folderSelection.servers.connectButton": "Connect to Server",
|
||||
"folderSelection.servers.remove": "Remove saved server",
|
||||
"folderSelection.servers.skipTls": "Self-signed TLS",
|
||||
"folderSelection.servers.errorTitle": "Remote Connection Failed",
|
||||
"folderSelection.servers.dialog.title": "Connect to Server",
|
||||
"folderSelection.servers.dialog.description": "Add a remote CodeNomad server and optionally open it right away.",
|
||||
"folderSelection.servers.dialog.name": "Server name",
|
||||
"folderSelection.servers.dialog.namePlaceholder": "Production Server",
|
||||
"folderSelection.servers.dialog.url": "Server URL",
|
||||
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||
"folderSelection.servers.dialog.skipTls": "Skip TLS verification for self-signed certificates.",
|
||||
"folderSelection.servers.dialog.cancel": "Cancel",
|
||||
"folderSelection.servers.dialog.save": "Save",
|
||||
"folderSelection.servers.dialog.connect": "Connect",
|
||||
"folderSelection.servers.dialog.connecting": "Connecting...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "Sessions",
|
||||
"instanceShell.leftPanel.instanceInfo": "Instance Info",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "Pin left drawer",
|
||||
"instanceShell.leftDrawer.unpin": "Unpin left drawer",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned",
|
||||
@@ -107,6 +106,8 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
|
||||
"instanceShell.rightPanel.toast.saveError": "Failed to save file",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "Yolo Mode",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Automatically approves permission requests for the current session. Use it only when you trust the tools being run.",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
|
||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||
@@ -150,6 +151,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "Select a session to configure Yolo mode.",
|
||||
"instanceShell.yoloMode.title": "Yolo mode",
|
||||
"instanceShell.yoloMode.description": "Automatically approve permission requests for this session. Disabled by default.",
|
||||
"instanceShell.yoloMode.badge": "Yolo mode",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "Yolo mode enabled",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "No background processes.",
|
||||
"instanceShell.backgroundProcesses.status": "Status: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
|
||||
"remoteAccess.addresses.loading": "Loading addresses…",
|
||||
"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.loopback": "Loopback",
|
||||
"remoteAccess.address.scope.internal": "Internal",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "Working",
|
||||
"sessionList.status.compacting": "Compacting",
|
||||
"sessionList.status.idle": "Idle",
|
||||
"sessionList.status.retrying": "Retrying",
|
||||
"sessionList.status.retryingIn": "Retrying in {seconds}s",
|
||||
"sessionList.status.retryTooltip": "{message} (Attempt {attempt})",
|
||||
"sessionList.status.retryToast": "{countdown}: {message} (Attempt {attempt})",
|
||||
"sessionList.status.needsPermission": "Needs Permission",
|
||||
"sessionList.status.needsInput": "Needs Input",
|
||||
"sessionList.expand.collapseAriaLabel": "Collapse session",
|
||||
|
||||
@@ -113,6 +113,15 @@ export const settingsMessages = {
|
||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||
"settings.opencode.runtime.title": "Runtime",
|
||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||
"settings.opencode.logLevel.title": "OpenCode Log Level",
|
||||
"settings.opencode.logLevel.subtitle": "Control the log verbosity used when launching new OpenCode instances.",
|
||||
"settings.opencode.logLevel.selector.title": "Default log level",
|
||||
"settings.opencode.logLevel.selector.subtitle": "Choose how verbose new OpenCode instances should be.",
|
||||
"settings.opencode.logLevel.option.debug": "Debug",
|
||||
"settings.opencode.logLevel.option.info": "Info",
|
||||
"settings.opencode.logLevel.option.warn": "Warn",
|
||||
"settings.opencode.logLevel.option.error": "Error",
|
||||
|
||||
|
||||
"settings.appearance.behavior.title": "Interaction",
|
||||
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
|
||||
@@ -186,4 +195,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "Saved",
|
||||
"settings.speech.save.unsaved": "Unsaved changes",
|
||||
"settings.speech.save.error": "Save failed",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
||||
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
||||
"toolCall.diff.viewMode.split": "Split",
|
||||
"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.ariaLabel": "Diagnostics",
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
|
||||
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
|
||||
|
||||
"commands.closeInstance.label": "Cerrar instancia",
|
||||
"commands.closeInstance.description": "Detener el servidor de la instancia actual",
|
||||
"commands.closeInstance.keywords": "detener, salir, cerrar",
|
||||
"commands.closeInstance.label": "Cerrar pestaña",
|
||||
"commands.closeInstance.description": "Cerrar la pestaña superior actual",
|
||||
"commands.closeInstance.keywords": "detener, salir, cerrar, pestaña",
|
||||
|
||||
"commands.nextInstance.label": "Siguiente instancia",
|
||||
"commands.nextInstance.description": "Cambiar a la siguiente pestaña de instancia",
|
||||
"commands.nextInstance.keywords": "cambiar, navegar",
|
||||
"commands.nextInstance.label": "Siguiente pestaña",
|
||||
"commands.nextInstance.description": "Cambiar a la siguiente pestaña superior",
|
||||
"commands.nextInstance.keywords": "cambiar, navegar, pestaña",
|
||||
|
||||
"commands.previousInstance.label": "Instancia anterior",
|
||||
"commands.previousInstance.description": "Cambiar a la pestaña de instancia anterior",
|
||||
"commands.previousInstance.keywords": "cambiar, navegar",
|
||||
"commands.previousInstance.label": "Pestaña anterior",
|
||||
"commands.previousInstance.description": "Cambiar a la pestaña superior anterior",
|
||||
"commands.previousInstance.keywords": "cambiar, navegar, pestaña",
|
||||
|
||||
"commands.newSession.label": "Nueva sesión",
|
||||
"commands.newSession.description": "Crear una nueva sesión principal",
|
||||
|
||||
@@ -2,35 +2,38 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.language.ariaLabel": "Idioma",
|
||||
|
||||
"folderSelection.logoAlt": "Logo de CodeNomad",
|
||||
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con IA",
|
||||
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con AI",
|
||||
|
||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
||||
"folderSelection.links.githubStars": "Estrellas de CodeNomad en GitHub",
|
||||
"folderSelection.links.githubStars": "Estrellas de GitHub de CodeNomad",
|
||||
"folderSelection.links.discord": "Discord de CodeNomad",
|
||||
|
||||
"folderSelection.empty.title": "No hay carpetas recientes",
|
||||
"folderSelection.empty.description": "Explora una carpeta para comenzar",
|
||||
"folderSelection.empty.description": "Busca una carpeta para comenzar",
|
||||
|
||||
"folderSelection.recent.title": "Carpetas recientes",
|
||||
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
|
||||
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
|
||||
"folderSelection.recent.remove": "Quitar de recientes",
|
||||
"folderSelection.recent.remove": "Eliminar de recientes",
|
||||
|
||||
"folderSelection.browse.title": "Explorar carpetas",
|
||||
"folderSelection.browse.title": "Buscar carpeta",
|
||||
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
|
||||
"folderSelection.browse.button": "Explorar carpetas",
|
||||
"folderSelection.browse.button": "Buscar carpetas",
|
||||
"folderSelection.browse.buttonOpening": "Abriendo...",
|
||||
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
|
||||
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
|
||||
"folderSelection.actions.connectButton": "Conectar servidor CodeNomad",
|
||||
|
||||
"folderSelection.advancedSettings": "Configuración avanzada",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
|
||||
"folderSelection.hints.navigate": "Navegar",
|
||||
"folderSelection.hints.select": "Seleccionar",
|
||||
"folderSelection.hints.remove": "Quitar",
|
||||
"folderSelection.hints.browse": "Explorar",
|
||||
"folderSelection.hints.remove": "Eliminar",
|
||||
"folderSelection.hints.browse": "Buscar",
|
||||
|
||||
"folderSelection.loading.title": "Iniciando instancia...",
|
||||
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
||||
"folderSelection.loading.subtitle": "Espera mientras preparamos tu espacio de trabajo.",
|
||||
|
||||
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
||||
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
||||
|
||||
"folderSelection.dialog.title": "Seleccionar workspace",
|
||||
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
||||
|
||||
"folderSelection.tabs.local": "Carpetas locales",
|
||||
"folderSelection.tabs.servers": "Servidores",
|
||||
"folderSelection.servers.title": "Servidores guardados",
|
||||
"folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva",
|
||||
"folderSelection.servers.count": "{count} servidores",
|
||||
"folderSelection.servers.empty.title": "No hay servidores guardados",
|
||||
"folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo",
|
||||
"folderSelection.servers.connectTitle": "Conectar a un servidor",
|
||||
"folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva",
|
||||
"folderSelection.servers.connectButton": "Conectar a un servidor",
|
||||
"folderSelection.servers.remove": "Eliminar servidor guardado",
|
||||
"folderSelection.servers.skipTls": "TLS autofirmado",
|
||||
"folderSelection.servers.errorTitle": "Falló la conexión remota",
|
||||
"folderSelection.servers.dialog.title": "Conectar a un servidor",
|
||||
"folderSelection.servers.dialog.description": "Añade un servidor remoto de CodeNomad y ábrelo ahora si quieres.",
|
||||
"folderSelection.servers.dialog.name": "Nombre del servidor",
|
||||
"folderSelection.servers.dialog.namePlaceholder": "Servidor de producción",
|
||||
"folderSelection.servers.dialog.url": "URL del servidor",
|
||||
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||
"folderSelection.servers.dialog.skipTls": "Omitir la verificación TLS para certificados autofirmados.",
|
||||
"folderSelection.servers.dialog.cancel": "Cancelar",
|
||||
"folderSelection.servers.dialog.save": "Guardar",
|
||||
"folderSelection.servers.dialog.connect": "Conectar",
|
||||
"folderSelection.servers.dialog.connecting": "Conectando...",
|
||||
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
||||
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "Sesiones",
|
||||
"instanceShell.leftPanel.instanceInfo": "Info de la instancia",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "Fijar panel izquierdo",
|
||||
"instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado",
|
||||
@@ -107,6 +106,8 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
|
||||
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "Modo yolo",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Aprueba automaticamente las solicitudes de permiso de la sesion actual. Usalo solo si confias en las herramientas que se estan ejecutando.",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
|
||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||
@@ -140,6 +141,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
|
||||
"instanceShell.plan.empty": "Aún no hay nada planificado.",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "Selecciona una sesion para configurar el modo yolo.",
|
||||
"instanceShell.yoloMode.title": "Modo yolo",
|
||||
"instanceShell.yoloMode.description": "Aprueba automaticamente las solicitudes de permiso de esta sesion. Esta desactivado por defecto.",
|
||||
"instanceShell.yoloMode.badge": "Modo yolo",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "Modo yolo activado",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
|
||||
"instanceShell.backgroundProcesses.status": "Estado: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.",
|
||||
"remoteAccess.addresses.loading": "Cargando direcciones…",
|
||||
"remoteAccess.addresses.none": "Aún no hay direcciones disponibles.",
|
||||
"remoteAccess.addresses.actions.showOther": "Mostrar {count} direcciones más",
|
||||
"remoteAccess.addresses.actions.hideOther": "Ocultar otras direcciones",
|
||||
"remoteAccess.address.scope.network": "Red",
|
||||
"remoteAccess.address.scope.loopback": "Loopback",
|
||||
"remoteAccess.address.scope.internal": "Interna",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "Trabajando",
|
||||
"sessionList.status.compacting": "Compactando",
|
||||
"sessionList.status.idle": "Inactiva",
|
||||
"sessionList.status.retrying": "Reintentando",
|
||||
"sessionList.status.retryingIn": "Reintentando en {seconds}s",
|
||||
"sessionList.status.retryTooltip": "{message} (Intento {attempt})",
|
||||
"sessionList.status.retryToast": "{countdown}: {message} (Intento {attempt})",
|
||||
"sessionList.status.needsPermission": "Requiere permiso",
|
||||
"sessionList.status.needsInput": "Requiere entrada",
|
||||
"sessionList.expand.collapseAriaLabel": "Colapsar sesión",
|
||||
|
||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||
"settings.opencode.runtime.title": "Runtime",
|
||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||
"settings.opencode.logLevel.title": "Nivel de logs de OpenCode",
|
||||
"settings.opencode.logLevel.subtitle": "Define el nivel de logs usado al iniciar nuevas instancias de OpenCode.",
|
||||
"settings.opencode.logLevel.selector.title": "Verbosidad de logs",
|
||||
"settings.opencode.logLevel.selector.subtitle": "Elige cuanta informacion deben registrar las nuevas instancias de OpenCode.",
|
||||
"settings.opencode.logLevel.option.debug": "Depuracion",
|
||||
"settings.opencode.logLevel.option.info": "Informacion",
|
||||
"settings.opencode.logLevel.option.warn": "Advertencia",
|
||||
"settings.opencode.logLevel.option.error": "Error",
|
||||
|
||||
"settings.appearance.behavior.title": "Interaccion",
|
||||
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
|
||||
@@ -186,4 +194,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "Guardado",
|
||||
"settings.speech.save.unsaved": "Cambios sin guardar",
|
||||
"settings.speech.save.error": "Error al guardar",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
||||
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
||||
"toolCall.diff.viewMode.split": "Dividida",
|
||||
"toolCall.diff.viewMode.unified": "Unificada",
|
||||
"toolCall.diff.switchToSplit": "Cambiar a vista dividida",
|
||||
"toolCall.diff.switchToUnified": "Cambiar a vista unificada",
|
||||
"toolCall.diff.enableWordWrap": "Activar ajuste de línea",
|
||||
"toolCall.diff.disableWordWrap": "Desactivar ajuste de línea",
|
||||
"toolCall.diff.copyPatch": "Copiar patch",
|
||||
|
||||
"toolCall.diagnostics.title": "Diagnósticos",
|
||||
"toolCall.diagnostics.ariaLabel": "Diagnósticos",
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
|
||||
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
||||
|
||||
"commands.closeInstance.label": "Fermer l'instance",
|
||||
"commands.closeInstance.description": "Arrêter le serveur de l'instance actuelle",
|
||||
"commands.closeInstance.keywords": "arrêter, quitter, fermer",
|
||||
"commands.closeInstance.label": "Fermer l'onglet",
|
||||
"commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel",
|
||||
"commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet",
|
||||
|
||||
"commands.nextInstance.label": "Instance suivante",
|
||||
"commands.nextInstance.description": "Passer à l'onglet d'instance suivant",
|
||||
"commands.nextInstance.keywords": "changer, naviguer, suivant",
|
||||
"commands.nextInstance.label": "Onglet suivant",
|
||||
"commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant",
|
||||
"commands.nextInstance.keywords": "changer, naviguer, suivant, onglet",
|
||||
|
||||
"commands.previousInstance.label": "Instance précédente",
|
||||
"commands.previousInstance.description": "Passer à l'onglet d'instance précédent",
|
||||
"commands.previousInstance.keywords": "changer, naviguer, précédent",
|
||||
"commands.previousInstance.label": "Onglet précédent",
|
||||
"commands.previousInstance.description": "Passer à l'onglet de premier niveau précédent",
|
||||
"commands.previousInstance.keywords": "changer, naviguer, précédent, onglet",
|
||||
|
||||
"commands.newSession.label": "Nouvelle session",
|
||||
"commands.newSession.description": "Créer une nouvelle session parente",
|
||||
|
||||
@@ -5,7 +5,7 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
|
||||
|
||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
||||
"folderSelection.links.githubStars": "Stars GitHub de CodeNomad",
|
||||
"folderSelection.links.githubStars": "Étoiles GitHub de CodeNomad",
|
||||
"folderSelection.links.discord": "Discord de CodeNomad",
|
||||
|
||||
"folderSelection.empty.title": "Aucun dossier récent",
|
||||
@@ -16,10 +16,13 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
|
||||
"folderSelection.recent.remove": "Retirer des récents",
|
||||
|
||||
"folderSelection.browse.title": "Parcourir les dossiers",
|
||||
"folderSelection.browse.title": "Parcourir un dossier",
|
||||
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
||||
"folderSelection.browse.button": "Parcourir les dossiers",
|
||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
||||
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
|
||||
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
|
||||
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
|
||||
|
||||
"folderSelection.advancedSettings": "Paramètres avancés",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
||||
|
||||
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||
|
||||
"folderSelection.tabs.local": "Dossiers locaux",
|
||||
"folderSelection.tabs.servers": "Serveurs",
|
||||
"folderSelection.servers.title": "Serveurs enregistrés",
|
||||
"folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre",
|
||||
"folderSelection.servers.count": "{count} serveurs",
|
||||
"folderSelection.servers.empty.title": "Aucun serveur enregistré",
|
||||
"folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil",
|
||||
"folderSelection.servers.connectTitle": "Se connecter à un serveur",
|
||||
"folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre",
|
||||
"folderSelection.servers.connectButton": "Se connecter à un serveur",
|
||||
"folderSelection.servers.remove": "Supprimer le serveur enregistré",
|
||||
"folderSelection.servers.skipTls": "TLS auto-signé",
|
||||
"folderSelection.servers.errorTitle": "Échec de la connexion distante",
|
||||
"folderSelection.servers.dialog.title": "Se connecter à un serveur",
|
||||
"folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.",
|
||||
"folderSelection.servers.dialog.name": "Nom du serveur",
|
||||
"folderSelection.servers.dialog.namePlaceholder": "Serveur de production",
|
||||
"folderSelection.servers.dialog.url": "URL du serveur",
|
||||
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||
"folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.",
|
||||
"folderSelection.servers.dialog.cancel": "Annuler",
|
||||
"folderSelection.servers.dialog.save": "Enregistrer",
|
||||
"folderSelection.servers.dialog.connect": "Se connecter",
|
||||
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "Sessions",
|
||||
"instanceShell.leftPanel.instanceInfo": "Infos de l'instance",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "Épingler le tiroir gauche",
|
||||
"instanceShell.leftDrawer.unpin": "Désépingler le tiroir gauche",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "Tiroir gauche épinglé",
|
||||
@@ -107,6 +106,8 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
|
||||
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "Mode yolo",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Approuve automatiquement les demandes d'autorisation pour la session actuelle. A utiliser seulement si vous faites confiance aux outils executes.",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
|
||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||
@@ -140,6 +141,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
|
||||
"instanceShell.plan.empty": "Aucun plan pour l'instant.",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "Selectionnez une session pour configurer le mode yolo.",
|
||||
"instanceShell.yoloMode.title": "Mode yolo",
|
||||
"instanceShell.yoloMode.description": "Approuve automatiquement les demandes d'autorisation pour cette session. Desactive par defaut.",
|
||||
"instanceShell.yoloMode.badge": "Mode yolo",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "Mode yolo active",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
|
||||
"instanceShell.backgroundProcesses.status": "Statut : {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
|
||||
"remoteAccess.addresses.loading": "Chargement des adresses…",
|
||||
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
|
||||
"remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses",
|
||||
"remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses",
|
||||
"remoteAccess.address.scope.network": "Réseau",
|
||||
"remoteAccess.address.scope.loopback": "Boucle locale",
|
||||
"remoteAccess.address.scope.internal": "Interne",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "En cours",
|
||||
"sessionList.status.compacting": "Compactage",
|
||||
"sessionList.status.idle": "Inactif",
|
||||
"sessionList.status.retrying": "Nouvelle tentative",
|
||||
"sessionList.status.retryingIn": "Nouvelle tentative dans {seconds}s",
|
||||
"sessionList.status.retryTooltip": "{message} (Tentative {attempt})",
|
||||
"sessionList.status.retryToast": "{countdown} : {message} (Tentative {attempt})",
|
||||
"sessionList.status.needsPermission": "Autorisation requise",
|
||||
"sessionList.status.needsInput": "Entrée requise",
|
||||
"sessionList.expand.collapseAriaLabel": "Réduire la session",
|
||||
|
||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||
"settings.opencode.runtime.title": "Runtime",
|
||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||
"settings.opencode.logLevel.title": "Niveau de logs OpenCode",
|
||||
"settings.opencode.logLevel.subtitle": "Definir le niveau de logs utilise au lancement des nouvelles instances OpenCode.",
|
||||
"settings.opencode.logLevel.selector.title": "Verbosite des logs",
|
||||
"settings.opencode.logLevel.selector.subtitle": "Choisir la quantite de journaux emise par les nouvelles instances OpenCode.",
|
||||
"settings.opencode.logLevel.option.debug": "Debogage",
|
||||
"settings.opencode.logLevel.option.info": "Info",
|
||||
"settings.opencode.logLevel.option.warn": "Avertissement",
|
||||
"settings.opencode.logLevel.option.error": "Erreur",
|
||||
|
||||
"settings.appearance.behavior.title": "Interaction",
|
||||
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
|
||||
@@ -186,4 +194,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "Enregistré",
|
||||
"settings.speech.save.unsaved": "Modifications non enregistrées",
|
||||
"settings.speech.save.error": "Échec de l'enregistrement",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
||||
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
||||
"toolCall.diff.viewMode.split": "Côte à côte",
|
||||
"toolCall.diff.viewMode.unified": "Unifié",
|
||||
"toolCall.diff.switchToSplit": "Passer à la vue côte à côte",
|
||||
"toolCall.diff.switchToUnified": "Passer à la vue unifiée",
|
||||
"toolCall.diff.enableWordWrap": "Activer le retour à la ligne",
|
||||
"toolCall.diff.disableWordWrap": "Désactiver le retour à la ligne",
|
||||
"toolCall.diff.copyPatch": "Copier le patch",
|
||||
|
||||
"toolCall.diagnostics.title": "Diagnostics",
|
||||
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
||||
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
||||
|
||||
"commands.closeInstance.label": "סגור מופע",
|
||||
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
|
||||
"commands.closeInstance.keywords": "עצור, סגור",
|
||||
"commands.closeInstance.label": "סגור לשונית",
|
||||
"commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית",
|
||||
"commands.closeInstance.keywords": "עצור, סגור, לשונית",
|
||||
|
||||
"commands.nextInstance.label": "מופע הבא",
|
||||
"commands.nextInstance.description": "עבור למופע הבא",
|
||||
"commands.nextInstance.keywords": "החלף, נווט",
|
||||
"commands.nextInstance.label": "הלשונית הבאה",
|
||||
"commands.nextInstance.description": "עבור ללשונית העליונה הבאה",
|
||||
"commands.nextInstance.keywords": "החלף, נווט, לשונית",
|
||||
|
||||
"commands.previousInstance.label": "מופע קודם",
|
||||
"commands.previousInstance.description": "עבור למופע הקודם",
|
||||
"commands.previousInstance.keywords": "החלף, נווט",
|
||||
"commands.previousInstance.label": "הלשונית הקודמת",
|
||||
"commands.previousInstance.description": "עבור ללשונית העליונה הקודמת",
|
||||
"commands.previousInstance.keywords": "החלף, נווט, לשונית",
|
||||
|
||||
"commands.newSession.label": "סשן חדש",
|
||||
"commands.newSession.description": "צור סשן הורה חדש",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
|
||||
"folderSelection.browse.button": "עיון בתיקיות",
|
||||
"folderSelection.browse.buttonOpening": "פותח...",
|
||||
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
|
||||
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
|
||||
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
|
||||
|
||||
"folderSelection.advancedSettings": "הגדרות מתקדמות",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
||||
|
||||
"folderSelection.dialog.title": "בחר סביבת עבודה",
|
||||
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
|
||||
|
||||
"folderSelection.tabs.local": "תיקיות מקומיות",
|
||||
"folderSelection.tabs.servers": "שרתים",
|
||||
"folderSelection.servers.title": "שרתים שמורים",
|
||||
"folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש",
|
||||
"folderSelection.servers.count": "{count} שרתים",
|
||||
"folderSelection.servers.empty.title": "אין שרתים שמורים",
|
||||
"folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה",
|
||||
"folderSelection.servers.connectTitle": "התחבר לשרת",
|
||||
"folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש",
|
||||
"folderSelection.servers.connectButton": "התחבר לשרת",
|
||||
"folderSelection.servers.remove": "הסר שרת שמור",
|
||||
"folderSelection.servers.skipTls": "TLS בחתימה עצמית",
|
||||
"folderSelection.servers.errorTitle": "החיבור המרוחק נכשל",
|
||||
"folderSelection.servers.dialog.title": "התחבר לשרת",
|
||||
"folderSelection.servers.dialog.description": "הוסף שרת CodeNomad מרוחק ופתח אותו מיד אם תרצה.",
|
||||
"folderSelection.servers.dialog.name": "שם השרת",
|
||||
"folderSelection.servers.dialog.namePlaceholder": "שרת ייצור",
|
||||
"folderSelection.servers.dialog.url": "כתובת השרת",
|
||||
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||
"folderSelection.servers.dialog.skipTls": "דלג על אימות TLS עבור תעודות בחתימה עצמית.",
|
||||
"folderSelection.servers.dialog.cancel": "ביטול",
|
||||
"folderSelection.servers.dialog.save": "שמור",
|
||||
"folderSelection.servers.dialog.connect": "התחבר",
|
||||
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
||||
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
||||
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "סשנים",
|
||||
"instanceShell.leftPanel.instanceInfo": "מידע על המופע",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
|
||||
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
|
||||
@@ -107,6 +106,8 @@ export const instanceMessages = {
|
||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל",
|
||||
"instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה",
|
||||
"instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ",
|
||||
"instanceShell.rightPanel.sections.yoloMode": "מצב Yolo",
|
||||
"instanceShell.rightPanel.sections.yoloMode.tooltip": "מאשר אוטומטית בקשות הרשאה עבור הסשן הנוכחי. השתמשו בזה רק אם אתם סומכים על הכלים שרצים.",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
|
||||
"instanceShell.rightPanel.sections.plan": "תוכנית",
|
||||
@@ -148,6 +149,12 @@ export const instanceMessages = {
|
||||
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
|
||||
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
|
||||
|
||||
"instanceShell.yoloMode.noSessionSelected": "בחרו סשן כדי להגדיר מצב Yolo.",
|
||||
"instanceShell.yoloMode.title": "מצב Yolo",
|
||||
"instanceShell.yoloMode.description": "מאשר אוטומטית בקשות הרשאה עבור הסשן הזה. כבוי כברירת מחדל.",
|
||||
"instanceShell.yoloMode.badge": "Yolo",
|
||||
"instanceShell.yoloMode.badgeAriaLabel": "מצב Yolo פעיל",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
|
||||
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
||||
"remoteAccess.addresses.loading": "טוען כתובות…",
|
||||
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
||||
"remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות",
|
||||
"remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות",
|
||||
"remoteAccess.address.scope.network": "רשת",
|
||||
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
||||
"remoteAccess.address.scope.internal": "פנימי",
|
||||
|
||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
||||
"sessionList.status.working": "עובד",
|
||||
"sessionList.status.compacting": "מסכם",
|
||||
"sessionList.status.idle": "מוכן",
|
||||
"sessionList.status.retrying": "מנסה שוב",
|
||||
"sessionList.status.retryingIn": "מנסה שוב בעוד {seconds}ש׳",
|
||||
"sessionList.status.retryTooltip": "{message} (ניסיון {attempt})",
|
||||
"sessionList.status.retryToast": "{countdown}: {message} (ניסיון {attempt})",
|
||||
"sessionList.status.needsPermission": "נדרש אישור",
|
||||
"sessionList.status.needsInput": "נדרש קלט",
|
||||
"sessionList.expand.collapseAriaLabel": "כווץ סשן",
|
||||
|
||||
@@ -112,6 +112,14 @@ export const settingsMessages = {
|
||||
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
|
||||
"settings.opencode.runtime.title": "סביבת ריצה",
|
||||
"settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.",
|
||||
"settings.opencode.logLevel.title": "רמת הלוגים של OpenCode",
|
||||
"settings.opencode.logLevel.subtitle": "הגדר את רמת הלוגים שבה ייעשה שימוש בעת הפעלת מופעי OpenCode חדשים.",
|
||||
"settings.opencode.logLevel.selector.title": "פירוט לוגים",
|
||||
"settings.opencode.logLevel.selector.subtitle": "בחר כמה לוגים מופעי OpenCode חדשים צריכים להפיק.",
|
||||
"settings.opencode.logLevel.option.debug": "ניפוי שגיאות",
|
||||
"settings.opencode.logLevel.option.info": "מידע",
|
||||
"settings.opencode.logLevel.option.warn": "אזהרה",
|
||||
"settings.opencode.logLevel.option.error": "שגיאה",
|
||||
|
||||
"settings.appearance.behavior.title": "אינטראקציה",
|
||||
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
|
||||
@@ -185,4 +193,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "נשמר",
|
||||
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
|
||||
"settings.speech.save.error": "השמירה נכשלה",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
||||
"toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff",
|
||||
"toolCall.diff.viewMode.split": "מפוצל",
|
||||
"toolCall.diff.viewMode.unified": "מאוחד",
|
||||
"toolCall.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
||||
"toolCall.diff.switchToUnified": "עבור לתצוגה מאוחדת",
|
||||
"toolCall.diff.enableWordWrap": "הפעל גלישת מילים",
|
||||
"toolCall.diff.disableWordWrap": "כבה גלישת מילים",
|
||||
"toolCall.diff.copyPatch": "העתק patch",
|
||||
|
||||
"toolCall.diagnostics.title": "אבחון",
|
||||
"toolCall.diagnostics.ariaLabel": "אבחון",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user