Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43db9cdb50 | ||
|
|
03ed3d3b2c | ||
|
|
a111de1af8 | ||
|
|
8a3b162be9 | ||
|
|
c62cb3ce4a | ||
|
|
d9811e735d | ||
|
|
1ce58b9dd9 | ||
|
|
1907a4da03 | ||
|
|
abf4c67fcc | ||
|
|
bc130ceb5b | ||
|
|
8505a43b16 | ||
|
|
2a3329b5ed | ||
|
|
c9c1cf21f0 | ||
|
|
c7d4f99e48 | ||
|
|
d50c00afb4 | ||
|
|
0ef57df3bc | ||
|
|
0739ec857c | ||
|
|
b060ab45ff | ||
|
|
af6429162f | ||
|
|
2e9ee2cde6 | ||
|
|
d45c0b9367 | ||
|
|
197898c01c | ||
|
|
0c0cfd2d22 | ||
|
|
5107ac207e | ||
|
|
1130066a33 | ||
|
|
403a3ff189 | ||
|
|
7996e514c4 | ||
|
|
141be2cde0 | ||
|
|
259d457209 | ||
|
|
d0a0325d7e | ||
|
|
19a4c3df16 | ||
|
|
10506920ac | ||
|
|
92c029d744 | ||
|
|
6eb3246d37 | ||
|
|
5c90de84de | ||
|
|
455a59f693 | ||
|
|
a89da02d6b | ||
|
|
69d9e95bee | ||
|
|
893d5f9296 | ||
|
|
e82e529a8f | ||
|
|
4f236ce36f | ||
|
|
2ffeb45a9c | ||
|
|
df16b64a95 | ||
|
|
f3c54df283 | ||
|
|
5658a9f62d | ||
|
|
9d6a5bcdc0 | ||
|
|
514b187b00 | ||
|
|
240acb7729 | ||
|
|
278b563c1a | ||
|
|
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 |
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -12068,7 +12068,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12105,7 +12105,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12147,7 +12147,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12155,7 +12155,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.13.3",
|
||||
"minServerVersion": "0.14.0",
|
||||
"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.3",
|
||||
"version": "0.14.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -13,6 +13,11 @@ type BackgroundProcess = {
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
type BackgroundProcessNotificationRequest = {
|
||||
sessionID: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
type BackgroundProcessOptions = {
|
||||
baseDir: string
|
||||
}
|
||||
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
|
||||
args: {
|
||||
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
||||
command: tool.schema.string().describe("Shell command to run in the workspace"),
|
||||
notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"),
|
||||
},
|
||||
async execute(args) {
|
||||
async execute(args, context) {
|
||||
assertCommandWithinBase(args.command, options.baseDir)
|
||||
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
|
||||
? {
|
||||
sessionID: context.sessionID,
|
||||
directory: context.directory,
|
||||
}
|
||||
: undefined
|
||||
const process = await request<BackgroundProcess>("", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: args.title, command: args.command }),
|
||||
body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
|
||||
})
|
||||
|
||||
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"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 }
|
||||
@@ -328,6 +376,8 @@ export interface ServerMeta {
|
||||
|
||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||
|
||||
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
|
||||
|
||||
export interface BackgroundProcess {
|
||||
id: string
|
||||
workspaceId: string
|
||||
@@ -340,6 +390,8 @@ export interface BackgroundProcess {
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
terminalReason?: BackgroundProcessTerminalReason
|
||||
notifyEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface BackgroundProcessListResponse {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
|
||||
import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
|
||||
|
||||
const ROOT_DIR = ".codenomad/background_processes"
|
||||
const INDEX_FILE = "index.json"
|
||||
@@ -27,6 +27,31 @@ interface RunningProcess {
|
||||
outputPath: string
|
||||
exitPromise: Promise<void>
|
||||
workspaceId: string
|
||||
completion?: ProcessCompletion
|
||||
}
|
||||
|
||||
interface ProcessCompletion {
|
||||
reason: BackgroundProcessTerminalReason
|
||||
endContext: "normal" | "workspace_cleanup"
|
||||
removeAfterFinalize?: boolean
|
||||
}
|
||||
|
||||
interface BackgroundProcessNotificationState {
|
||||
sessionID: string
|
||||
directory: string
|
||||
sentAt?: string
|
||||
}
|
||||
|
||||
interface PersistedBackgroundProcess extends BackgroundProcess {
|
||||
notify?: BackgroundProcessNotificationState
|
||||
}
|
||||
|
||||
interface StartOptions {
|
||||
notify?: boolean
|
||||
notification?: {
|
||||
sessionID: string
|
||||
directory: string
|
||||
}
|
||||
}
|
||||
|
||||
export class BackgroundProcessManager {
|
||||
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const enriched = await Promise.all(
|
||||
records.map(async (record) => ({
|
||||
...record,
|
||||
...this.toPublicProcess(record),
|
||||
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
||||
})),
|
||||
)
|
||||
return enriched
|
||||
}
|
||||
|
||||
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
|
||||
async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
|
||||
this.killProcessTree(child, "SIGTERM")
|
||||
})
|
||||
|
||||
const record: BackgroundProcess = {
|
||||
|
||||
const record: PersistedBackgroundProcess = {
|
||||
id,
|
||||
workspaceId,
|
||||
title,
|
||||
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
outputSizeBytes: 0,
|
||||
notify: options.notify && options.notification
|
||||
? {
|
||||
sessionID: options.notification.sessionID,
|
||||
directory: options.notification.directory,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
const runningState: RunningProcess = {
|
||||
id,
|
||||
child,
|
||||
outputPath,
|
||||
exitPromise: Promise.resolve(),
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
|
||||
await new Promise<void>((resolve) => outputStream.end(resolve))
|
||||
this.running.delete(id)
|
||||
|
||||
record.status = this.statusFromExit(code)
|
||||
const completion = runningState.completion ?? this.completionFromExit(code)
|
||||
|
||||
record.terminalReason = completion.reason
|
||||
record.status = this.statusFromReason(completion.reason)
|
||||
record.exitCode = code === null ? undefined : code
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
await this.finalizeRecord(workspaceId, record, completion)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
||||
runningState.exitPromise = exitPromise
|
||||
|
||||
this.running.set(id, runningState)
|
||||
|
||||
let lastPublishAt = 0
|
||||
const maybePublishSize = () => {
|
||||
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
return record
|
||||
return this.toPublicProcess(record)
|
||||
}
|
||||
|
||||
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
running.completion = { reason: "user_stopped", endContext: "normal" }
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
const updated = await this.findProcess(workspaceId, processId)
|
||||
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
|
||||
}
|
||||
|
||||
if (record.status === "running") {
|
||||
record.status = "stopped"
|
||||
record.terminalReason = "user_stopped"
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
|
||||
}
|
||||
|
||||
return record
|
||||
return this.toPublicProcess(record)
|
||||
}
|
||||
|
||||
async terminate(workspaceId: string, processId: string): Promise<void> {
|
||||
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
return
|
||||
}
|
||||
|
||||
await this.removeFromIndex(workspaceId, processId)
|
||||
await this.removeProcessDir(workspaceId, processId)
|
||||
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.removed", properties: { processId } },
|
||||
record.status = "stopped"
|
||||
record.terminalReason = "user_terminated"
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
await this.finalizeRecord(workspaceId, record, {
|
||||
reason: "user_terminated",
|
||||
endContext: "normal",
|
||||
removeAfterFinalize: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
|
||||
private async cleanupWorkspace(workspaceId: string) {
|
||||
for (const [, running] of this.running.entries()) {
|
||||
if (running.workspaceId !== workspaceId) continue
|
||||
running.completion = {
|
||||
reason: "user_terminated",
|
||||
endContext: "workspace_cleanup",
|
||||
removeAfterFinalize: true,
|
||||
}
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
|
||||
return args
|
||||
}
|
||||
|
||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||
if (code === null) return "stopped"
|
||||
if (code === 0) return "stopped"
|
||||
return "error"
|
||||
private completionFromExit(code: number | null): ProcessCompletion {
|
||||
if (code === 0) {
|
||||
return { reason: "finished", endContext: "normal" }
|
||||
}
|
||||
|
||||
return { reason: "failed", endContext: "normal" }
|
||||
}
|
||||
|
||||
private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus {
|
||||
if (reason === "failed") return "error"
|
||||
return "stopped"
|
||||
}
|
||||
|
||||
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
|
||||
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
|
||||
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
|
||||
}
|
||||
|
||||
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
return records.find((entry) => entry.id === processId) ?? null
|
||||
}
|
||||
|
||||
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
|
||||
private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
if (!existsSync(indexPath)) return []
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath, "utf-8")
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
|
||||
return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
|
||||
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const index = records.findIndex((entry) => entry.id === record.id)
|
||||
if (index >= 0) {
|
||||
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
|
||||
await this.writeIndex(workspaceId, next)
|
||||
}
|
||||
|
||||
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
|
||||
private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
||||
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
||||
@@ -503,14 +560,139 @@ export class BackgroundProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
|
||||
private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.updated", properties: { process: record } },
|
||||
event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
|
||||
})
|
||||
}
|
||||
|
||||
private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess {
|
||||
return {
|
||||
id: record.id,
|
||||
workspaceId: record.workspaceId,
|
||||
title: record.title,
|
||||
command: record.command,
|
||||
cwd: record.cwd,
|
||||
status: record.status,
|
||||
pid: record.pid,
|
||||
startedAt: record.startedAt,
|
||||
stoppedAt: record.stoppedAt,
|
||||
exitCode: record.exitCode,
|
||||
outputSizeBytes: record.outputSizeBytes,
|
||||
terminalReason: record.terminalReason,
|
||||
notifyEnabled: Boolean(record.notify),
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) {
|
||||
if (this.shouldSendCompletionPrompt(record, completion)) {
|
||||
try {
|
||||
await this.sendCompletionPrompt(workspaceId, record)
|
||||
if (record.notify) {
|
||||
record.notify.sentAt = new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt")
|
||||
}
|
||||
}
|
||||
|
||||
if (completion.removeAfterFinalize) {
|
||||
await this.removeFromIndex(workspaceId, record.id)
|
||||
await this.removeProcessDir(workspaceId, record.id)
|
||||
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.removed", properties: { processId: record.id } },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
}
|
||||
|
||||
private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) {
|
||||
if (completion.endContext === "workspace_cleanup") return false
|
||||
if (!record.notify) return false
|
||||
return !record.notify.sentAt
|
||||
}
|
||||
|
||||
private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||
const notify = record.notify
|
||||
if (!notify || !record.terminalReason) return
|
||||
|
||||
if (!this.deps.workspaceManager.get(workspaceId)) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
|
||||
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
|
||||
if (!port) {
|
||||
throw new Error("Workspace instance is not ready")
|
||||
}
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
|
||||
}
|
||||
|
||||
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
if (authorization) {
|
||||
headers.authorization = authorization
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: this.buildSyntheticCompletionPrompt(record),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Prompt request failed with ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
|
||||
const ref = `Background process "${record.title}" (${record.id})`
|
||||
|
||||
switch (record.terminalReason) {
|
||||
case "finished":
|
||||
return `${ref} finished successfully.`
|
||||
case "failed":
|
||||
return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.`
|
||||
case "user_stopped":
|
||||
return `${ref} was stopped by user.`
|
||||
case "user_terminated":
|
||||
return `${ref} was terminated by user.`
|
||||
}
|
||||
|
||||
return `${ref} ended.`
|
||||
}
|
||||
|
||||
private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string {
|
||||
return `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
|
||||
}
|
||||
|
||||
private escapeTaggedText(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||
const random = randomBytes(3).toString("hex")
|
||||
|
||||
@@ -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,15 @@ 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"
|
||||
import { ClientConnectionManager } from "./clients/connection-manager"
|
||||
import { PluginChannelManager } from "./plugins/channel"
|
||||
import { VoiceModeManager } from "./plugins/voice-mode"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -55,6 +59,7 @@ interface CliOptions {
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
authCookieName: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
@@ -100,6 +105,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 +149,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authCookieName: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
@@ -185,6 +196,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 +278,7 @@ async function main() {
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
cookieName: options.authCookieName,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
@@ -306,6 +319,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,
|
||||
@@ -363,6 +381,14 @@ async function main() {
|
||||
|
||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
|
||||
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
||||
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||
|
||||
@@ -391,7 +417,11 @@ async function main() {
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
@@ -412,7 +442,11 @@ async function main() {
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
@@ -442,18 +476,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 +502,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 +512,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 +545,18 @@ 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 {
|
||||
clientConnectionManager.shutdown()
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Client connection manager shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
|
||||
@@ -19,13 +19,13 @@ export class VoiceModeManager {
|
||||
})
|
||||
}
|
||||
|
||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
|
||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean {
|
||||
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||
this.options.logger.debug(
|
||||
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||
"Ignoring voice mode enable for disconnected client connection",
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const key = getConnectionKey(connection)
|
||||
@@ -44,6 +44,7 @@ export class VoiceModeManager {
|
||||
|
||||
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||
this.publishIfChanged(instanceId)
|
||||
return true
|
||||
}
|
||||
|
||||
syncInstance(instanceId: string): void {
|
||||
@@ -76,7 +77,10 @@ export class VoiceModeManager {
|
||||
this.aggregateByInstance.delete(instanceId)
|
||||
}
|
||||
|
||||
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
|
||||
this.options.logger.debug(
|
||||
{ instanceId, enabled },
|
||||
"Broadcasting aggregate voice mode",
|
||||
)
|
||||
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +52,11 @@ interface HttpServerDeps {
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
speechService: SpeechService
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
clientConnectionManager: ClientConnectionManager
|
||||
pluginChannel: PluginChannelManager
|
||||
voiceModeManager: VoiceModeManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
@@ -176,13 +185,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: deps.logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
@@ -203,7 +205,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(?:\/|$)/)
|
||||
@@ -262,7 +264,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
registerClient: registerSseClient,
|
||||
logger: sseLogger,
|
||||
connectionManager: clientConnectionManager,
|
||||
connectionManager: deps.clientConnectionManager,
|
||||
})
|
||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerStorageRoutes(app, {
|
||||
@@ -270,13 +272,21 @@ 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,
|
||||
logger: proxyLogger,
|
||||
channel: pluginChannel,
|
||||
voiceModeManager,
|
||||
channel: deps.pluginChannel,
|
||||
voiceModeManager: deps.voiceModeManager,
|
||||
})
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
@@ -342,7 +352,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
clientConnectionManager.shutdown()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
@@ -353,6 +362,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 +908,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(".")
|
||||
|
||||
@@ -9,6 +9,21 @@ interface RouteDeps {
|
||||
const StartSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
command: z.string().trim().min(1),
|
||||
notify: z.boolean().optional(),
|
||||
notification: z
|
||||
.object({
|
||||
sessionID: z.string().trim().min(1),
|
||||
directory: z.string().trim().min(1),
|
||||
})
|
||||
.optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.notify && !value.notification) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Notification metadata is required when notify is enabled",
|
||||
path: ["notification"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const OutputQuerySchema = z.object({
|
||||
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
|
||||
|
||||
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
||||
const payload = StartSchema.parse(request.body ?? {})
|
||||
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
|
||||
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, {
|
||||
notify: payload.notify,
|
||||
notification: payload.notification,
|
||||
})
|
||||
reply.code(201)
|
||||
return process
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,11 +66,17 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||
deps.voiceModeManager.setEnabled(
|
||||
const applied = deps.voiceModeManager.setEnabled(
|
||||
request.params.id,
|
||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||
payload.enabled,
|
||||
)
|
||||
|
||||
if (payload.enabled && !applied) {
|
||||
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
|
||||
return
|
||||
}
|
||||
|
||||
return { enabled: payload.enabled }
|
||||
})
|
||||
|
||||
|
||||
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.13.3"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.13.3"
|
||||
version = "0.14.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
@@ -28,4 +28,4 @@ url = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
#[cfg(windows)]
|
||||
use std::ffi::c_void;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
#[cfg(windows)]
|
||||
use std::mem::{size_of, zeroed};
|
||||
use std::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -16,15 +20,98 @@ 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)]
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[derive(Debug)]
|
||||
struct WindowsJobObject {
|
||||
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
|
||||
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
|
||||
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl WindowsJobObject {
|
||||
fn create() -> anyhow::Result<Self> {
|
||||
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
|
||||
if handle.is_null() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"CreateJobObjectW failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
let ok = unsafe {
|
||||
SetInformationJobObject(
|
||||
handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&mut info as *mut _ as *mut c_void,
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
unsafe {
|
||||
CloseHandle(handle);
|
||||
}
|
||||
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
|
||||
}
|
||||
|
||||
Ok(Self { handle })
|
||||
}
|
||||
|
||||
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
|
||||
let process_handle = child.as_raw_handle() as HANDLE;
|
||||
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"AssignProcessToJobObject failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for WindowsJobObject {
|
||||
fn drop(&mut self) {
|
||||
if !self.handle.is_null() {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe impl Send for WindowsJobObject {}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe impl Sync for WindowsJobObject {}
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
}
|
||||
@@ -48,7 +135,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 +211,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 +250,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 +263,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 +286,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)]
|
||||
@@ -344,6 +450,8 @@ impl Default for CliStatus {
|
||||
pub struct CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
#[cfg(windows)]
|
||||
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
@@ -353,6 +461,8 @@ impl CliProcessManager {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
#[cfg(windows)]
|
||||
job: Arc::new(Mutex::new(None)),
|
||||
ready: Arc::new(AtomicBool::new(false)),
|
||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
@@ -375,6 +485,8 @@ impl CliProcessManager {
|
||||
|
||||
let status_arc = self.status.clone();
|
||||
let child_arc = self.child.clone();
|
||||
#[cfg(windows)]
|
||||
let job_arc = self.job.clone();
|
||||
let ready_flag = self.ready.clone();
|
||||
let token_arc = self.bootstrap_token.clone();
|
||||
thread::spawn(move || {
|
||||
@@ -382,6 +494,8 @@ impl CliProcessManager {
|
||||
app.clone(),
|
||||
status_arc.clone(),
|
||||
child_arc,
|
||||
#[cfg(windows)]
|
||||
job_arc,
|
||||
ready_flag,
|
||||
token_arc,
|
||||
dev,
|
||||
@@ -401,11 +515,12 @@ impl CliProcessManager {
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
#[cfg(windows)]
|
||||
let _job = self.job.lock().take();
|
||||
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(windows)]
|
||||
let mut forced_tree_shutdown = false;
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
let pid = child.id() as i32;
|
||||
@@ -427,18 +542,16 @@ impl CliProcessManager {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
#[cfg(windows)]
|
||||
if !forced_tree_shutdown
|
||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
||||
{
|
||||
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
|
||||
log_line(&format!(
|
||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
||||
child.id()
|
||||
));
|
||||
forced_tree_shutdown = true;
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||
@@ -457,11 +570,7 @@ impl CliProcessManager {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !forced_tree_shutdown
|
||||
&& !kill_process_tree_windows(child.id(), true)
|
||||
{
|
||||
let _ = child.kill();
|
||||
} else if forced_tree_shutdown {
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
@@ -472,6 +581,9 @@ impl CliProcessManager {
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(windows)]
|
||||
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
|
||||
}
|
||||
|
||||
let mut status = self.status.lock();
|
||||
@@ -492,6 +604,7 @@ impl CliProcessManager {
|
||||
app: AppHandle,
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child_holder: Arc<Mutex<Option<Child>>>,
|
||||
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
dev: bool,
|
||||
@@ -503,7 +616,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 +628,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 +641,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 +655,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);
|
||||
@@ -568,6 +686,22 @@ impl CliProcessManager {
|
||||
|
||||
let pid = child.id();
|
||||
log_line(&format!("spawned pid={pid}"));
|
||||
#[cfg(windows)]
|
||||
match WindowsJobObject::create().and_then(|job| {
|
||||
job.assign_child(&child)?;
|
||||
Ok(job)
|
||||
}) {
|
||||
Ok(job) => {
|
||||
log_line(&format!("attached pid={pid} to Windows job object"));
|
||||
*job_holder.lock() = Some(job);
|
||||
}
|
||||
Err(err) => {
|
||||
log_line(&format!(
|
||||
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut locked = status.lock();
|
||||
locked.pid = Some(pid);
|
||||
@@ -584,6 +718,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 +733,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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -623,6 +775,8 @@ impl CliProcessManager {
|
||||
let status_clone = status.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let child_holder_clone = child_holder.clone();
|
||||
#[cfg(windows)]
|
||||
let job_holder_clone = job_holder.clone();
|
||||
thread::spawn(move || {
|
||||
let timeout = Duration::from_secs(60);
|
||||
thread::sleep(timeout);
|
||||
@@ -677,6 +831,10 @@ impl CliProcessManager {
|
||||
// Drop the handle after the process exits so other callers
|
||||
// don't attempt to stop/kill a finished process.
|
||||
*guard = None;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = job_holder_clone.lock().take();
|
||||
}
|
||||
Some(status)
|
||||
}
|
||||
None => None,
|
||||
@@ -731,10 +889,11 @@ 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 +925,16 @@ 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 +947,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 +971,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 +1071,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 +1135,58 @@ 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 +1288,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,16 @@ 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,
|
||||
};
|
||||
@@ -30,7 +33,7 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
|
||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
||||
const ZOOM_STEP: f64 = 0.2;
|
||||
const ZOOM_STEP: f64 = 0.1;
|
||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||
|
||||
@@ -41,6 +44,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 +131,32 @@ 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 +167,58 @@ 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 +372,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 +410,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 +543,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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"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,15 +1,17 @@
|
||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { loadMonaco } from "../../lib/monaco/setup"
|
||||
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||
import { useTheme } from "../../lib/theme"
|
||||
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
|
||||
|
||||
interface MonacoDiffViewerProps {
|
||||
scopeKey: string
|
||||
path: string
|
||||
before: string
|
||||
after: string
|
||||
patch?: string
|
||||
before?: string
|
||||
after?: string
|
||||
viewMode?: "split" | "unified"
|
||||
contextMode?: "expanded" | "collapsed"
|
||||
wordWrap?: "on" | "off"
|
||||
@@ -23,6 +25,16 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
let monaco: any = null
|
||||
const [ready, setReady] = createSignal(false)
|
||||
|
||||
const resolvedContent = createMemo(() => {
|
||||
if (props.patch !== undefined && props.patch !== null) {
|
||||
return parsePatchToBeforeAfter(props.patch)
|
||||
}
|
||||
return {
|
||||
before: props.before ?? "",
|
||||
after: props.after ?? "",
|
||||
}
|
||||
})
|
||||
|
||||
const disposeEditor = () => {
|
||||
try {
|
||||
diffEditor?.setModel(null as any)
|
||||
@@ -115,11 +127,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||
const { before, after } = resolvedContent()
|
||||
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
|
||||
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
|
||||
|
||||
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
|
||||
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
|
||||
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId })
|
||||
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
|
||||
diffEditor.setModel({ original, modified })
|
||||
|
||||
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
||||
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -115,23 +115,22 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
patch={String((file() as any).patch || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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"
|
||||
import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
|
||||
import type { Instance } from "../../../../../types/instance"
|
||||
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||
@@ -187,6 +187,24 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
<div class="status-process-header">
|
||||
<span class="status-process-title">{process.title}</span>
|
||||
<div class="status-process-meta">
|
||||
<span
|
||||
classList={{
|
||||
"text-success": Boolean(process.notifyEnabled),
|
||||
"text-tertiary": !process.notifyEnabled,
|
||||
}}
|
||||
aria-label={props.t(
|
||||
process.notifyEnabled
|
||||
? "instanceShell.backgroundProcesses.notify.enabled"
|
||||
: "instanceShell.backgroundProcesses.notify.disabled",
|
||||
)}
|
||||
title={props.t(
|
||||
process.notifyEnabled
|
||||
? "instanceShell.backgroundProcesses.notify.enabled"
|
||||
: "instanceShell.backgroundProcesses.notify.disabled",
|
||||
)}
|
||||
>
|
||||
<BellRing class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||
<span>
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
|
||||
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
|
||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { ClientPart, MessageInfo } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
|
||||
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
||||
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"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
import { createFollowScroll } from "../lib/follow-scroll"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
|
||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
@@ -130,7 +132,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) {
|
||||
@@ -229,6 +231,12 @@ function isContentPartType(type: unknown): boolean {
|
||||
return type === "text" || type === "file"
|
||||
}
|
||||
|
||||
function isVisibleContentPart(part: ClientPart): boolean {
|
||||
if (!part || !isContentPartType((part as any).type)) return false
|
||||
if (isHiddenSyntheticTextPart(part)) return false
|
||||
return partHasRenderableText(part)
|
||||
}
|
||||
|
||||
function MessageContentItem(props: MessageContentItemProps) {
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
@@ -262,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
return resolved
|
||||
})
|
||||
|
||||
const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
|
||||
|
||||
const showAgentMeta = createMemo(() => {
|
||||
const current = record()
|
||||
if (!current) return false
|
||||
if (current.role !== "assistant") return false
|
||||
|
||||
const currentParts = parts()
|
||||
if (!currentParts.some((part) => partHasRenderableText(part))) {
|
||||
if (visibleParts().length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -284,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
if (!isSupportedPartType(part)) continue
|
||||
|
||||
if (!isContentPartType((part as any).type)) continue
|
||||
if (partHasRenderableText(part)) {
|
||||
return false
|
||||
if (isVisibleContentPart(part)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
@@ -298,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
<MessageItem
|
||||
record={resolvedRecord()}
|
||||
messageInfo={messageInfo()}
|
||||
parts={parts()}
|
||||
parts={visibleParts()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={isQueued()}
|
||||
@@ -619,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const lastAssistantIdx = props.lastAssistantIndex()
|
||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||
|
||||
// Intentionally untracked: messageInfoVersion updates should not trigger
|
||||
// a full message block rebuild; record revision is the invalidation key.
|
||||
const info = untrack(messageInfo)
|
||||
const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
|
||||
|
||||
const cacheSignature = [
|
||||
current.id,
|
||||
current.revision,
|
||||
messageInfoVersion,
|
||||
isQueued ? 1 : 0,
|
||||
props.showThinking() ? 1 : 0,
|
||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||
@@ -637,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
return cachedBlock.block
|
||||
}
|
||||
|
||||
// Only capture info after cache check fails - ensures fresh data on version bump
|
||||
const info = untrack(messageInfo)
|
||||
|
||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
||||
const items: MessageBlockItem[] = []
|
||||
const blockContentKeys: string[] = []
|
||||
@@ -803,19 +815,19 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
data-message-id={resolvedBlock().record.id}
|
||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||
>
|
||||
<For each={resolvedBlock().items}>
|
||||
<Index each={resolvedBlock().items}>
|
||||
{(item, index) => (
|
||||
<Switch>
|
||||
<Match when={item.type === "content"}>
|
||||
<Match when={item().type === "content"}>
|
||||
<MessageContentItem
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageId={(item as ContentDisplayItem).messageId}
|
||||
startPartId={(item as ContentDisplayItem).startPartId}
|
||||
messageId={(item() as ContentDisplayItem).messageId}
|
||||
startPartId={(item() as ContentDisplayItem).startPartId}
|
||||
messageIndex={props.messageIndex}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showDeleteMessage={index() === 0}
|
||||
showDeleteMessage={index === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onRevert={props.onRevert}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
@@ -825,18 +837,18 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "tool"}>
|
||||
<Match when={item().type === "tool"}>
|
||||
{(() => {
|
||||
const toolItem = item as ToolDisplayItem
|
||||
const toolItem = item() as ToolDisplayItem
|
||||
return (
|
||||
<div class="tool-call-message" data-key={toolItem.key}>
|
||||
<ToolCallItem
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageId={toolItem.messageId}
|
||||
partId={toolItem.partId}
|
||||
showDeleteMessage={index() === 0}
|
||||
<ToolCallItem
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageId={toolItem.messageId}
|
||||
partId={toolItem.partId}
|
||||
showDeleteMessage={index === 0}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
@@ -849,13 +861,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={item.type === "step-start"}>
|
||||
<Match when={item().type === "step-start"}>
|
||||
<StepCard
|
||||
kind="start"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
part={(item() as StepDisplayItem).part}
|
||||
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||
showAgentMeta
|
||||
showDeleteMessage={index() === 0}
|
||||
showDeleteMessage={index === 0}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={props.messageId}
|
||||
@@ -865,14 +877,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
<Match when={item().type === "step-finish"}>
|
||||
<StepCard
|
||||
kind="finish"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
part={(item() as StepDisplayItem).part}
|
||||
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||
showUsage={props.showUsageMetrics()}
|
||||
borderColor={(item as StepDisplayItem).accentColor}
|
||||
showDeleteMessage={index() === 0}
|
||||
borderColor={(item() as StepDisplayItem).accentColor}
|
||||
showDeleteMessage={index === 0}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={props.messageId}
|
||||
@@ -882,31 +894,31 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "compaction"}>
|
||||
<Match when={item().type === "compaction"}>
|
||||
<CompactionCard
|
||||
part={(item as CompactionDisplayItem).part}
|
||||
messageInfo={(item as CompactionDisplayItem).messageInfo}
|
||||
borderColor={(item as CompactionDisplayItem).accentColor}
|
||||
part={(item() as CompactionDisplayItem).part}
|
||||
messageInfo={(item() as CompactionDisplayItem).messageInfo}
|
||||
borderColor={(item() as CompactionDisplayItem).accentColor}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as CompactionDisplayItem).messageId}
|
||||
showDeleteMessage={index() === 0}
|
||||
messageId={(item() as CompactionDisplayItem).messageId}
|
||||
showDeleteMessage={index === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
<Match when={item().type === "reasoning"}>
|
||||
<ReasoningCard
|
||||
part={(item as ReasoningDisplayItem).part}
|
||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
||||
part={(item() as ReasoningDisplayItem).part}
|
||||
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as ReasoningDisplayItem).messageId}
|
||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||
showDeleteMessage={index() === 0}
|
||||
messageId={(item() as ReasoningDisplayItem).messageId}
|
||||
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
|
||||
showDeleteMessage={index === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
@@ -916,7 +928,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</Index>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -1098,17 +1110,23 @@ function StepCard(props: StepCardProps) {
|
||||
return null
|
||||
}
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant" || !info.tokens) {
|
||||
const part = props.part as any
|
||||
|
||||
// step-finish parts have tokens embedded; also check messageInfo
|
||||
const partTokens = part?.tokens
|
||||
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
|
||||
const tokens = partTokens ?? infoTokens
|
||||
if (!tokens) {
|
||||
return null
|
||||
}
|
||||
const tokens = info.tokens
|
||||
|
||||
return {
|
||||
input: tokens.input ?? 0,
|
||||
output: tokens.output ?? 0,
|
||||
reasoning: tokens.reasoning ?? 0,
|
||||
cacheRead: tokens.cache?.read ?? 0,
|
||||
cacheWrite: tokens.cache?.write ?? 0,
|
||||
cost: info.cost ?? 0,
|
||||
cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1293,14 +1311,23 @@ interface ReasoningCardProps {
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
function ReasoningStreamOutput(props: {
|
||||
text: Accessor<string>
|
||||
scrollTopSnapshot: Accessor<number>
|
||||
setScrollTopSnapshot: (next: number) => void
|
||||
onContentRendered?: () => void
|
||||
ariaLabel: string
|
||||
}) {
|
||||
let preRef: HTMLPreElement | undefined
|
||||
let pendingRenderNotificationFrame: number | null = null
|
||||
|
||||
const followScroll = createFollowScroll({
|
||||
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
|
||||
sentinelClassName: "reasoning-scroll-sentinel",
|
||||
})
|
||||
|
||||
const notifyContentRendered = () => {
|
||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
||||
if (pendingRenderNotificationFrame !== null) {
|
||||
@@ -1312,6 +1339,15 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const nextText = props.text()
|
||||
if (preRef && preRef.textContent !== nextText) {
|
||||
preRef.textContent = nextText
|
||||
}
|
||||
followScroll.restoreAfterRender()
|
||||
notifyContentRendered()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingRenderNotificationFrame !== null) {
|
||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||
@@ -1319,6 +1355,37 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={followScroll.registerContainer}
|
||||
class="message-reasoning-output"
|
||||
role="region"
|
||||
aria-label={props.ariaLabel}
|
||||
onScroll={followScroll.handleScroll}
|
||||
>
|
||||
<pre
|
||||
ref={(element) => {
|
||||
preRef = element || undefined
|
||||
if (preRef) {
|
||||
preRef.textContent = props.text() || ""
|
||||
}
|
||||
}}
|
||||
class="message-reasoning-text"
|
||||
dir="auto"
|
||||
/>
|
||||
{followScroll.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
})
|
||||
@@ -1393,12 +1460,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
createEffect(() => {
|
||||
if (!expanded()) return
|
||||
reasoningText()
|
||||
notifyContentRendered()
|
||||
})
|
||||
|
||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||
|
||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||
@@ -1553,9 +1614,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
<Show when={expanded()}>
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
||||
</div>
|
||||
<ReasoningStreamOutput
|
||||
text={reasoningText}
|
||||
scrollTopSnapshot={scrollTopSnapshot}
|
||||
setScrollTopSnapshot={setScrollTopSnapshot}
|
||||
onContentRendered={props.onContentRendered}
|
||||
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
||||
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
@@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
const getRawContent = () => {
|
||||
return props.parts
|
||||
.filter(part => part.type === "text")
|
||||
.map(part => (part as { text?: string }).text || "")
|
||||
.filter(text => text.trim().length > 0)
|
||||
.filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part))
|
||||
.map((part) => (part as { text?: string }).text || "")
|
||||
.filter((text) => text.trim().length > 0)
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent() && !isGenerating()) {
|
||||
if (!hasContent() && !isGenerating()) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
const shouldHideTextPart = () => {
|
||||
const part = props.part
|
||||
if (!part || part.type !== "text") return false
|
||||
|
||||
const isSynthetic = Boolean((part as any).synthetic)
|
||||
if (!isSynthetic) return false
|
||||
|
||||
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
|
||||
if (props.messageType === "user") {
|
||||
const primaryId = props.primaryUserTextPartId
|
||||
if (!primaryId) return false
|
||||
return part.id !== primaryId
|
||||
}
|
||||
|
||||
// Hide synthetic assistant text.
|
||||
return true
|
||||
return Boolean((part as any).synthetic)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
||||
import { MoreHorizontal, Trash, X } from "lucide-solid"
|
||||
import { MoreHorizontal, Pause, Trash, X } from "lucide-solid"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlock from "./message-block"
|
||||
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
||||
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
export interface MessageSectionProps {
|
||||
@@ -40,12 +42,40 @@ export interface MessageSectionProps {
|
||||
}
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
const { preferences } = useConfig()
|
||||
const { preferences, updatePreferences } = useConfig()
|
||||
const { t } = useI18n()
|
||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
|
||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||
const visibleMessageIds = createMemo(() => {
|
||||
const resolvedStore = store()
|
||||
return messageIds().filter((messageId) => {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return false
|
||||
|
||||
if (buildTimelineSegments(props.instanceId, record, t).length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (record.role !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
const info = resolvedStore.getMessageInfo(messageId)
|
||||
if (!info || info.role !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (info.error) {
|
||||
return true
|
||||
}
|
||||
|
||||
const timeInfo = info.time as { created: number; end?: number } | undefined
|
||||
return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0))
|
||||
})
|
||||
})
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: props.instanceId,
|
||||
@@ -129,6 +159,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 +347,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[]>([])
|
||||
@@ -571,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||
|
||||
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
|
||||
// Only preferences should force a follow-token re-anchor. Message/session
|
||||
// revision churn at the end of a turn (message.updated, session.idle, etc.)
|
||||
// should not trigger an immediate scroll-to-bottom.
|
||||
const followToken = createMemo(() => preferenceSignature())
|
||||
|
||||
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
||||
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
||||
@@ -601,6 +630,35 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||
|
||||
const lastVisibleMessageId = createMemo(() => {
|
||||
const ids = visibleMessageIds()
|
||||
return ids[ids.length - 1] ?? null
|
||||
})
|
||||
|
||||
const autoPinHoldTargetKey = createMemo(() => {
|
||||
if (!holdLongAssistantRepliesEnabled()) return null
|
||||
const messageId = lastVisibleMessageId()
|
||||
return isAssistantTextMessage(messageId) ? messageId : null
|
||||
})
|
||||
|
||||
function toggleHoldLongAssistantReplies() {
|
||||
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
|
||||
}
|
||||
|
||||
function isAssistantTextMessage(messageId: string | null | undefined) {
|
||||
if (!messageId) return false
|
||||
const resolvedStore = store()
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record || record.role !== "assistant") return false
|
||||
|
||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
|
||||
return orderedParts.some((part) => {
|
||||
if ((part as any)?.type !== "text") return false
|
||||
if (partHasRenderableText(part)) return true
|
||||
return typeof (part as { text?: unknown }).text === "string"
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const api = listApi()
|
||||
if (!api) return
|
||||
@@ -615,7 +673,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const api = listApi()
|
||||
if (!element || !api) return
|
||||
if (props.loading) return
|
||||
if (messageIds().length === 0) return
|
||||
if (visibleMessageIds().length === 0) return
|
||||
if (didRestoreScroll()) return
|
||||
|
||||
scrollCache.restore(element, {
|
||||
@@ -734,88 +792,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 +949,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(() => {
|
||||
@@ -989,7 +1065,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
data-scroll-buttons={scrollButtonsCount()}
|
||||
>
|
||||
<VirtualFollowList
|
||||
items={messageIds}
|
||||
items={visibleMessageIds}
|
||||
getKey={(messageId) => messageId}
|
||||
getAnchorId={getMessageAnchorId}
|
||||
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
||||
@@ -1003,6 +1079,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
initialAutoScroll={initialAutoScroll}
|
||||
resetKey={() => props.sessionId}
|
||||
followToken={followToken}
|
||||
autoPinHoldTargetKey={autoPinHoldTargetKey}
|
||||
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
|
||||
resolveAutoPinHoldElement={(itemWrapper, key) => {
|
||||
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
|
||||
return candidates[candidates.length - 1] ?? null
|
||||
}}
|
||||
onScroll={() => {
|
||||
clearQuoteSelection()
|
||||
scrollCache.persist(streamElement())
|
||||
@@ -1033,9 +1115,55 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
||||
registerApi={(api) => setListApi(api)}
|
||||
registerState={(state) => setListState(state)}
|
||||
renderControls={(state, api) => (
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
|
||||
onClick={toggleHoldLongAssistantReplies}
|
||||
aria-label={
|
||||
holdLongAssistantRepliesEnabled()
|
||||
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||
}
|
||||
title={
|
||||
holdLongAssistantRepliesEnabled()
|
||||
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||
}
|
||||
>
|
||||
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<Show when={state.showScrollTopButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => api.scrollToTop()}
|
||||
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↑
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={state.showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => api.scrollToBottom()}
|
||||
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
renderBeforeItems={() => (
|
||||
<>
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<Show when={!props.loading && visibleMessageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
import { Portal } from "solid-js/web"
|
||||
import MessagePreview from "./message-preview"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import { isHiddenSyntheticTextPart } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
@@ -53,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
|
||||
const LONG_PRESS_MS = 500
|
||||
const JITTER_THRESHOLD = 10
|
||||
const ABSOLUTE_TOKEN_CAP = 10000
|
||||
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
@@ -65,6 +69,13 @@ interface PendingSegment {
|
||||
hasPrimaryText: boolean
|
||||
}
|
||||
|
||||
interface TimelineSegmentState {
|
||||
deleteHovered: boolean
|
||||
deleteSelected: boolean
|
||||
hasActivePermission: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
function truncateText(value: string): string {
|
||||
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||
return value
|
||||
@@ -105,6 +116,7 @@ function collectReasoningText(part: ClientPart): string {
|
||||
|
||||
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||
if (!part) return ""
|
||||
if (isHiddenSyntheticTextPart(part)) return ""
|
||||
if (typeof (part as any).text === "string") {
|
||||
return (part as any).text as string
|
||||
}
|
||||
@@ -349,6 +361,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const clearHoverPreview = () => {
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
@@ -356,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||
closeTimer = window.setTimeout(() => {
|
||||
closeTimer = null
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
clearHoverPreview()
|
||||
}, 160)
|
||||
}
|
||||
|
||||
@@ -397,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
clearHoverPreview()
|
||||
})
|
||||
|
||||
// --- Selection & histogram rib state ---
|
||||
@@ -416,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
// on activation, resize, or expansion — NOT on every scroll frame.
|
||||
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
||||
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let xrayOverlayRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -447,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (renderVirtualizedTimeline()) {
|
||||
if (hoveredSegment()) {
|
||||
clearHoverPreview()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isSelectionActive()) return
|
||||
if (!scrollContainerRef || !xrayOverlayRef) return
|
||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||
@@ -475,6 +500,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const renderVirtualizedTimeline = createMemo(() => !isSelectionActive())
|
||||
|
||||
createEffect(on(renderVirtualizedTimeline, () => {
|
||||
clearHoverPreview()
|
||||
}))
|
||||
|
||||
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
||||
|
||||
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
||||
@@ -577,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
wasLongPress = true
|
||||
|
||||
// Scroll anchoring: preserve visual position of the pressed badge.
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
@@ -629,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
|
||||
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
||||
if (!activeId) return
|
||||
const element = buttonRefs.get(activeId)
|
||||
if (!element) return
|
||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||
if (renderVirtualizedTimeline()) {
|
||||
const index = segmentIndexById().get(activeId)
|
||||
if (index !== undefined) {
|
||||
virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const element = buttonRefs.get(activeId)
|
||||
if (!element) return
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}, 120) : null
|
||||
onCleanup(() => {
|
||||
@@ -682,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
return map
|
||||
})
|
||||
|
||||
const segmentIndexById = createMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
|
||||
return map
|
||||
})
|
||||
|
||||
const segmentStates = createMemo(() => {
|
||||
const hover = deleteHover()
|
||||
const selectedMessages = props.selectedMessageIds?.()
|
||||
const expandedMessages = props.expandedMessageIds?.()
|
||||
const resolvedStore = store()
|
||||
const indexMap = messageIdToSessionIndex()
|
||||
const selectionActive = isSelectionActive()
|
||||
const result = new Map<string, TimelineSegmentState>()
|
||||
|
||||
for (const segment of props.segments) {
|
||||
let deleteHovered = false
|
||||
if (hover.kind === "message") {
|
||||
deleteHovered = hover.messageId === segment.messageId
|
||||
} else if (hover.kind === "deleteUpTo") {
|
||||
const targetIndex = indexMap.get(hover.messageId)
|
||||
const segmentIndex = indexMap.get(segment.messageId)
|
||||
deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex
|
||||
}
|
||||
|
||||
const deleteSelected = selectedMessages?.has(segment.messageId) ?? false
|
||||
|
||||
let hasActivePermission = false
|
||||
if (segment.type === "tool") {
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
for (const partId of partIds) {
|
||||
const permissionState = resolvedStore.getPermissionState(segment.messageId, partId)
|
||||
if (permissionState?.active) {
|
||||
hasActivePermission = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hidden = segment.type === "tool" && !(
|
||||
showTools()
|
||||
|| expandedMessages?.has(segment.messageId)
|
||||
|| selectionActive
|
||||
|| props.activeSegmentId === segment.id
|
||||
|| hasActivePermission
|
||||
|| deleteHovered
|
||||
|| deleteSelected
|
||||
)
|
||||
|
||||
result.set(segment.id, {
|
||||
deleteHovered,
|
||||
deleteSelected,
|
||||
hasActivePermission,
|
||||
hidden,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const segmentStateFor = (segmentId: string): TimelineSegmentState => {
|
||||
return segmentStates().get(segmentId) ?? {
|
||||
deleteHovered: false,
|
||||
deleteSelected: false,
|
||||
hasActivePermission: false,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
const segmentSpacerHeights = createMemo(() => {
|
||||
const states = segmentStates()
|
||||
const result = new Map<string, string>()
|
||||
let previousVisible: TimelineSegment | null = null
|
||||
|
||||
for (let index = 0; index < props.segments.length; index += 1) {
|
||||
const segment = props.segments[index]
|
||||
const state = states.get(segment.id)
|
||||
|
||||
if (state?.hidden) {
|
||||
result.set(segment.id, "0")
|
||||
continue
|
||||
}
|
||||
|
||||
if (!previousVisible) {
|
||||
result.set(segment.id, "0")
|
||||
previousVisible = segment
|
||||
continue
|
||||
}
|
||||
|
||||
const previousRaw = index > 0 ? props.segments[index - 1] : null
|
||||
const startsVisibleToolGroup = segment.type === "tool"
|
||||
&& (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId)
|
||||
const startsCollapsedToolGroup = segment.type === "assistant"
|
||||
&& previousVisible.messageId !== segment.messageId
|
||||
&& messagesWithTools().has(segment.messageId)
|
||||
&& previousRaw?.type === "tool"
|
||||
&& previousRaw.messageId === segment.messageId
|
||||
const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction")
|
||||
&& previousVisible.type === "assistant"
|
||||
&& messagesWithTools().has(previousVisible.messageId)
|
||||
|
||||
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
|
||||
result.set(
|
||||
segment.id,
|
||||
gapUnits === 1
|
||||
? "var(--message-timeline-segment-gap)"
|
||||
: "calc(var(--message-timeline-segment-gap) * 2)",
|
||||
)
|
||||
|
||||
previousVisible = segment
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-timeline-container">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
ref={(element) => {
|
||||
scrollContainerRef = element
|
||||
setScrollElement(element)
|
||||
}}
|
||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||
role="navigation"
|
||||
aria-label={t("messageTimeline.ariaLabel")}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<For each={props.segments}>
|
||||
{(segment, segIndex) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
<Show
|
||||
when={renderVirtualizedTimeline()}
|
||||
fallback={(
|
||||
<For each={props.segments}>
|
||||
{(segment, segIndex) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeSegmentId === segment.id
|
||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||
const state = () => segmentStateFor(segment.id)
|
||||
const isDeleteHovered = () => state().deleteHovered
|
||||
const isDeleteSelected = () => state().deleteSelected
|
||||
const hasActivePermission = () => state().hasActivePermission
|
||||
const isHidden = () => state().hidden
|
||||
|
||||
const groupRole = (): "child" | "parent" | "none" => {
|
||||
if (segment.type === "tool") return "child"
|
||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||
return "none"
|
||||
}
|
||||
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
if (hasActivePermission()) {
|
||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
}
|
||||
if (segment.type === "compaction") {
|
||||
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="message-timeline-item">
|
||||
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||
let anchorOffset: number | null = null
|
||||
if (stableBtn && scrollContainerRef) {
|
||||
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||
const desired = stableBtn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)}
|
||||
>
|
||||
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
|
||||
{(segment, index) => {
|
||||
const segIndex = () => index()
|
||||
const isActive = () => props.activeSegmentId === segment.id
|
||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||
|
||||
const isDeleteHovered = () => {
|
||||
const hover = deleteHover() as DeleteHoverState
|
||||
if (hover.kind === "message") {
|
||||
return hover.messageId === segment.messageId
|
||||
}
|
||||
|
||||
if (hover.kind === "deleteUpTo") {
|
||||
const indexMap = messageIdToSessionIndex()
|
||||
const targetIndex = indexMap.get(hover.messageId)
|
||||
if (targetIndex === undefined) return false
|
||||
const segmentIndex = indexMap.get(segment.messageId)
|
||||
if (segmentIndex === undefined) return false
|
||||
return segmentIndex >= targetIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const isDeleteSelected = () => {
|
||||
const selected = props.selectedMessageIds?.()
|
||||
if (!selected) return false
|
||||
return selected.has(segment.messageId)
|
||||
}
|
||||
|
||||
const hasActivePermission = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
if (partIds.length === 0) return false
|
||||
for (const partId of partIds) {
|
||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
||||
if (permissionState?.active) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
|
||||
const isHidden = () =>
|
||||
segment.type === "tool" &&
|
||||
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
|
||||
const state = () => segmentStateFor(segment.id)
|
||||
const isDeleteHovered = () => state().deleteHovered
|
||||
const isDeleteSelected = () => state().deleteSelected
|
||||
const hasActivePermission = () => state().hasActivePermission
|
||||
const isHidden = () => state().hidden
|
||||
|
||||
// Group visual indicators: tools belong to the same message as their
|
||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
||||
@@ -744,18 +962,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||
return "none"
|
||||
}
|
||||
const isGroupStart = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const idx = segIndex()
|
||||
const prev = idx > 0 ? props.segments[idx - 1] : null
|
||||
// First tool in the message's run: either nothing before, or previous
|
||||
// segment is from a different message or is not a tool.
|
||||
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
|
||||
}
|
||||
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
if (hasActivePermission()) {
|
||||
if (hasActivePermission()) {
|
||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
@@ -765,95 +975,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
|
||||
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Capture scroll anchor before selection changes may toggle
|
||||
// tool segment visibility, which shifts timeline layout.
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
// In selection mode, plain click scrolls to the message
|
||||
// instead of clearing. Selection is cleared by clicking
|
||||
// anywhere inside the chat container or pressing Esc.
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
// Restore scroll anchor: keep the clicked badge at the same
|
||||
// visual position after hidden tools appear or disappear.
|
||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
||||
const desired = btn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
return (
|
||||
<div class="message-timeline-item">
|
||||
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||
<button
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||
let anchorOffset: number | null = null
|
||||
if (stableBtn && scrollContainerRef) {
|
||||
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||
const desired = stableBtn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Virtualizer>
|
||||
</Show>
|
||||
<Show when={previewData()}>
|
||||
{(data) => {
|
||||
onCleanup(() => setTooltipElement(null))
|
||||
return (
|
||||
<div
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
/>
|
||||
</div>
|
||||
<Portal>
|
||||
<div
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
@@ -540,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)
|
||||
|
||||
@@ -169,18 +169,25 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
||||
const textarea = options.getTextarea()
|
||||
const start = textarea ? textarea.selectionStart : current.length
|
||||
const end = textarea ? textarea.selectionEnd : current.length
|
||||
const wasCursorAtEnd = end === current.length
|
||||
const wasScrolledToBottom = textarea
|
||||
? textarea.scrollHeight - (textarea.scrollTop + textarea.clientHeight) <= 4
|
||||
: false
|
||||
const before = current.slice(0, start)
|
||||
const after = current.slice(end)
|
||||
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
|
||||
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
|
||||
const prefix = ""
|
||||
const suffix = after.length > 0 ? (/^\s/.test(after) ? "" : " ") : " "
|
||||
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
||||
const cursor = before.length + prefix.length + text.length
|
||||
const cursor = before.length + prefix.length + text.length + suffix.length
|
||||
|
||||
options.setPrompt(nextValue)
|
||||
if (textarea) {
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
if (wasCursorAtEnd || wasScrolledToBottom) {
|
||||
textarea.scrollTop = textarea.scrollHeight
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -79,11 +79,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
requestAnimationFrame(() => scrollToBottomHandle?.())
|
||||
})
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!props.isActive) return
|
||||
if (!shouldScrollToBottomOnActivate()) return
|
||||
scheduleScrollToBottom()
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => props.isActive,
|
||||
(isActive, wasActive) => {
|
||||
if (!isActive) return
|
||||
if (wasActive === true) return
|
||||
if (!shouldScrollToBottomOnActivate()) return
|
||||
scheduleScrollToBottom()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -332,16 +338,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||
onFork={handleFork}
|
||||
isActive={props.isActive}
|
||||
registerScrollToBottom={(fn) => {
|
||||
scrollToBottomHandle = fn
|
||||
if (props.isActive) {
|
||||
if (shouldScrollToBottomOnActivate()) {
|
||||
scheduleScrollToBottom()
|
||||
}
|
||||
}
|
||||
}}
|
||||
onFork={handleFork}
|
||||
isActive={props.isActive}
|
||||
registerScrollToBottom={(fn) => {
|
||||
scrollToBottomHandle = fn
|
||||
}}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -334,7 +334,7 @@ const Field: Component<{
|
||||
<div class="settings-toggle-title">{props.label}</div>
|
||||
<div class="settings-toggle-caption">{props.caption}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
|
||||
<div class="flex items-center gap-2 w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
|
||||
{props.icon}
|
||||
<input
|
||||
type={props.type ?? "text"}
|
||||
@@ -361,7 +361,7 @@ const SelectField: Component<{
|
||||
<div class="settings-toggle-title">{props.label}</div>
|
||||
<div class="settings-toggle-caption">{props.caption}</div>
|
||||
</div>
|
||||
<div class="min-w-[18rem] max-w-[24rem] w-full">
|
||||
<div class="w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
|
||||
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
|
||||
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||
</select>
|
||||
|
||||
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,4 +1,4 @@
|
||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
|
||||
import { stringify as stringifyYaml } from "yaml"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
@@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
import { createFollowScroll } from "../lib/follow-scroll"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
||||
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
||||
function makeRenderCacheKey(
|
||||
toolCallId?: string | null,
|
||||
@@ -82,6 +81,27 @@ interface ToolCallProps {
|
||||
forceCollapsed?: boolean
|
||||
}
|
||||
|
||||
function ToolStatusIndicator(props: { status: Accessor<string> }) {
|
||||
const isVisible = (value: string) => props.status() === value
|
||||
|
||||
return (
|
||||
<span class="tool-call-header-status" aria-hidden="true" data-status={props.status() || "pending"}>
|
||||
<span style={{ display: isVisible("pending") ? "inline-flex" : "none" }}>
|
||||
<Hourglass class="w-4 h-4" />
|
||||
</span>
|
||||
<span style={{ display: isVisible("running") ? "inline-flex" : "none" }}>
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
</span>
|
||||
<span style={{ display: isVisible("completed") ? "inline-flex" : "none" }}>
|
||||
<Check class="w-4 h-4" />
|
||||
</span>
|
||||
<span style={{ display: isVisible("error") ? "inline-flex" : "none" }}>
|
||||
<XCircle class="w-4 h-4" />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolCallDetails(props: {
|
||||
toolCallMemo: () => ToolCallPart
|
||||
toolState: () => ToolState | undefined
|
||||
@@ -166,179 +186,25 @@ function ToolCallDetails(props: {
|
||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||
|
||||
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
|
||||
let pendingScrollFrame: number | null = null
|
||||
let pendingAnchorScroll: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let lastKnownScrollTop = props.scrollTopSnapshot()
|
||||
|
||||
function restoreScrollPosition(forceBottom = false) {
|
||||
const container = scrollContainerRef
|
||||
if (!container) return
|
||||
if (forceBottom) {
|
||||
container.scrollTop = container.scrollHeight
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
} else {
|
||||
container.scrollTop = lastKnownScrollTop
|
||||
}
|
||||
}
|
||||
|
||||
const persistScrollSnapshot = (element?: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
lastKnownScrollTop = element.scrollTop
|
||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
}
|
||||
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement) {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
const handlePointerIntent = () => markUserScrollIntent()
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAnchorScroll(immediate = false) {
|
||||
if (!autoScroll()) return
|
||||
const sentinel = bottomSentinel()
|
||||
const container = scrollContainerRef
|
||||
if (!sentinel || !container) return
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||
pendingAnchorScroll = null
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const sentinelRect = sentinel.getBoundingClientRect()
|
||||
const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX
|
||||
if (Math.abs(delta) > 1) {
|
||||
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
||||
}
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
})
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const container = scrollContainer()
|
||||
if (!container) return
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
pendingScrollFrame = requestAnimationFrame(() => {
|
||||
pendingScrollFrame = null
|
||||
const atBottom = bottomSentinelVisible()
|
||||
if (isUserScroll) {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
handleScroll()
|
||||
persistScrollSnapshot(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleScrollRendered = () => {
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition(autoScroll())
|
||||
scheduleAnchorScroll(true)
|
||||
})
|
||||
}
|
||||
|
||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
||||
const next = element || undefined
|
||||
if (next === scrollContainerRef) {
|
||||
return
|
||||
}
|
||||
scrollContainerRef = next
|
||||
setScrollContainer(scrollContainerRef)
|
||||
if (scrollContainerRef) {
|
||||
// Refresh our snapshot on mount (e.g. when remounting after collapse)
|
||||
lastKnownScrollTop = props.scrollTopSnapshot()
|
||||
restoreScrollPosition(autoScroll())
|
||||
}
|
||||
}
|
||||
const followScroll = createFollowScroll({
|
||||
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
|
||||
sentinelClassName: "tool-call-scroll-sentinel",
|
||||
})
|
||||
|
||||
const scrollHelpers: ToolScrollHelpers = {
|
||||
registerContainer: (element, options) => {
|
||||
if (options?.disableTracking) return
|
||||
initializeScrollContainer(element)
|
||||
},
|
||||
handleScroll: handleScrollEvent,
|
||||
renderSentinel: (options) => {
|
||||
if (options?.disableTracking) return null
|
||||
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
|
||||
followScroll.registerContainer(element, options)
|
||||
},
|
||||
handleScroll: followScroll.handleScroll,
|
||||
renderSentinel: followScroll.renderSentinel,
|
||||
restoreAfterRender: followScroll.restoreAfterRender,
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollContainer()
|
||||
if (!container) return
|
||||
attachScrollIntentListeners(container)
|
||||
onCleanup(() => {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollContainer()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.target === sentinel) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
const handleScrollRendered = () => {
|
||||
scrollHelpers.restoreAfterRender()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const permission = permissionDetails()
|
||||
@@ -564,11 +430,13 @@ function ToolCallDetails(props: {
|
||||
partVersion={options.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={options.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
forceCollapsed={options.forceCollapsed}
|
||||
/>
|
||||
)
|
||||
},
|
||||
scrollHelpers,
|
||||
onContentRendered: props.onContentRendered,
|
||||
}
|
||||
|
||||
let previousPartVersion: number | undefined
|
||||
@@ -581,12 +449,12 @@ function ToolCallDetails(props: {
|
||||
return
|
||||
}
|
||||
previousPartVersion = version
|
||||
scheduleAnchorScroll(true)
|
||||
scrollHelpers.restoreAfterRender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (autoScroll()) {
|
||||
scheduleAnchorScroll(true)
|
||||
if (followScroll.autoScroll()) {
|
||||
scrollHelpers.restoreAfterRender()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -634,21 +502,6 @@ function ToolCallDetails(props: {
|
||||
/>
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
pendingScrollFrame = null
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="tool-call-details">
|
||||
<Show
|
||||
@@ -850,24 +703,6 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return !current
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const statusIcon = () => {
|
||||
const status = toolState()?.status || ""
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return <Hourglass class="w-4 h-4" />
|
||||
case "running":
|
||||
return <Loader2 class="w-4 h-4 animate-spin" />
|
||||
case "completed":
|
||||
return <Check class="w-4 h-4" />
|
||||
case "error":
|
||||
return <XCircle class="w-4 h-4" />
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass = () => {
|
||||
const status = toolState()?.status || "pending"
|
||||
return `tool-call-status-${status}`
|
||||
@@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<span class="tool-call-header-status" aria-hidden="true">
|
||||
{statusIcon()}
|
||||
</span>
|
||||
<ToolStatusIndicator status={status} />
|
||||
</div>
|
||||
|
||||
<Show when={expanded()}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||
import type { RenderCache } from "../../types/message"
|
||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||
import { escapeHtml } from "../../lib/text-render-utils"
|
||||
@@ -11,6 +11,97 @@ type CacheHandle = {
|
||||
set(value: unknown): void
|
||||
}
|
||||
|
||||
export interface StableAnsiStreamUpdater {
|
||||
update: (element: HTMLElement, content: string) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater {
|
||||
const renderer = createAnsiStreamRenderer()
|
||||
let previousContent = ""
|
||||
let ansiActive = false
|
||||
|
||||
return {
|
||||
update(element: HTMLElement, content: string) {
|
||||
const resetStreaming = !previousContent || !content.startsWith(previousContent)
|
||||
|
||||
if (resetStreaming) {
|
||||
ansiActive = hasAnsi(content)
|
||||
renderer.reset()
|
||||
element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content)
|
||||
previousContent = content
|
||||
return
|
||||
}
|
||||
|
||||
const delta = content.slice(previousContent.length)
|
||||
if (delta.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!ansiActive && hasAnsi(delta)) {
|
||||
ansiActive = true
|
||||
renderer.reset()
|
||||
element.innerHTML = renderer.render(content)
|
||||
previousContent = content
|
||||
return
|
||||
}
|
||||
|
||||
if (ansiActive) {
|
||||
const htmlChunk = renderer.render(delta)
|
||||
if (htmlChunk.length > 0) {
|
||||
element.insertAdjacentHTML("beforeend", htmlChunk)
|
||||
}
|
||||
} else {
|
||||
const escapedDelta = escapeHtml(delta)
|
||||
if (escapedDelta.length > 0) {
|
||||
element.insertAdjacentHTML("beforeend", escapedDelta)
|
||||
}
|
||||
}
|
||||
|
||||
previousContent = content
|
||||
},
|
||||
reset() {
|
||||
previousContent = ""
|
||||
ansiActive = false
|
||||
renderer.reset()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function StreamingAnsiContent(props: {
|
||||
html: string
|
||||
htmlChunk?: string
|
||||
updateMode: "replace" | "append" | "noop"
|
||||
}) {
|
||||
let preRef: HTMLPreElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const element = preRef
|
||||
if (!element) return
|
||||
if (props.updateMode === "noop") return
|
||||
if (props.updateMode === "append") {
|
||||
if (element.innerHTML.length === 0) {
|
||||
element.innerHTML = props.html
|
||||
return
|
||||
}
|
||||
const chunk = props.htmlChunk ?? ""
|
||||
if (chunk.length > 0) {
|
||||
element.insertAdjacentHTML("beforeend", chunk)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (element.innerHTML !== props.html) {
|
||||
element.innerHTML = props.html
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
preRef = undefined
|
||||
})
|
||||
|
||||
return <pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||
}
|
||||
|
||||
export function createAnsiContentRenderer(params: {
|
||||
ansiRunningCache: CacheHandle
|
||||
ansiFinalCache: CacheHandle
|
||||
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
|
||||
const isRunningVariant = options.variant === "running"
|
||||
const disableScrollTracking = !isRunningVariant
|
||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||
let updateMode: "replace" | "append" | "noop" = "replace"
|
||||
let htmlChunk = ""
|
||||
|
||||
let nextCache: AnsiRenderCache
|
||||
|
||||
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
|
||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||
|
||||
if (resetStreaming) {
|
||||
updateMode = "replace"
|
||||
const detectedAnsi = hasAnsi(content)
|
||||
if (detectedAnsi) {
|
||||
runningAnsiRenderer.reset()
|
||||
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
|
||||
} else {
|
||||
const delta = content.slice(cached.text.length)
|
||||
if (delta.length === 0) {
|
||||
updateMode = "noop"
|
||||
nextCache = { ...cached, mode }
|
||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||
updateMode = "replace"
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else if (cached.hasAnsi) {
|
||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
||||
const appendedHtml = runningAnsiRenderer.render(delta)
|
||||
updateMode = "append"
|
||||
htmlChunk = appendedHtml
|
||||
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
|
||||
} else {
|
||||
updateMode = "append"
|
||||
htmlChunk = escapeHtml(delta)
|
||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||
}
|
||||
}
|
||||
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
||||
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
|
||||
<StreamingAnsiContent html={nextCache.html} htmlChunk={htmlChunk} updateMode={updateMode} />
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</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,60 +94,94 @@ 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()
|
||||
}
|
||||
params.handleScrollRendered()
|
||||
params.onContentRendered?.()
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,107 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolRenderer, ToolScrollHelpers } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||
import { tGlobal } from "../../../lib/i18n"
|
||||
import { createStableAnsiStreamUpdater } from "../ansi-render"
|
||||
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
|
||||
|
||||
function RunningBashOutput(props: {
|
||||
content: Accessor<string>
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}) {
|
||||
let preRef: HTMLPreElement | undefined
|
||||
const updater = createStableAnsiStreamUpdater()
|
||||
|
||||
createEffect(() => {
|
||||
const element = preRef
|
||||
if (!element) return
|
||||
updater.update(element, props.content())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
preRef = undefined
|
||||
updater.reset()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown"
|
||||
ref={props.scrollHelpers?.registerContainer}
|
||||
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||
>
|
||||
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||
{props.scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BashToolBody(props: {
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}) {
|
||||
const state = createMemo(() => props.toolState())
|
||||
|
||||
const joinedContent = createMemo(() => {
|
||||
const current = state()
|
||||
if (!current || current.status === "pending") return ""
|
||||
|
||||
const { input, metadata } = readToolStatePayload(current)
|
||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||
const outputResult = formatUnknown(
|
||||
isToolStateCompleted(current)
|
||||
? current.output
|
||||
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
|
||||
? metadata.output
|
||||
: undefined,
|
||||
)
|
||||
return [command, outputResult?.text].filter(Boolean).join("\n")
|
||||
})
|
||||
|
||||
const finalMarkdown = createMemo(() => {
|
||||
const current = state()
|
||||
const content = joinedContent()
|
||||
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (hasAnsi(content)) {
|
||||
return null
|
||||
}
|
||||
return ensureMarkdownContent(content, "bash", true)
|
||||
})
|
||||
|
||||
const finalAnsiHtml = createMemo(() => {
|
||||
const current = state()
|
||||
const content = joinedContent()
|
||||
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (!hasAnsi(content)) {
|
||||
return null
|
||||
}
|
||||
return ansiToHtml(content)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={state() && joinedContent().length > 0}>
|
||||
<Show
|
||||
when={state()?.status === "running"}
|
||||
fallback={
|
||||
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
|
||||
{(html) => (
|
||||
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
|
||||
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const bashRenderer: ToolRenderer = {
|
||||
tools: ["bash"],
|
||||
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
|
||||
const timeoutLabel = `${timeout}ms`
|
||||
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const { input, metadata } = readToolStatePayload(state)
|
||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||
const outputResult = formatUnknown(
|
||||
isToolStateCompleted(state)
|
||||
? state.output
|
||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
||||
? metadata.output
|
||||
: undefined,
|
||||
)
|
||||
const parts = [command, outputResult?.text].filter(Boolean)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const joined = parts.join("\n")
|
||||
if (state.status === "running") {
|
||||
return renderAnsi({ content: joined, variant: "running" })
|
||||
}
|
||||
|
||||
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
|
||||
if (ansiBody) {
|
||||
return ansiBody
|
||||
}
|
||||
|
||||
const content = ensureMarkdownContent(joined, "bash", true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content })
|
||||
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
|
||||
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
|
||||
const { input } = readToolStatePayload(state)
|
||||
return describeTaskTitle(input)
|
||||
},
|
||||
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
||||
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
||||
|
||||
@@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = {
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const childCount = childToolKeys().length
|
||||
const legacyCount = legacyItems().length
|
||||
if (childCount === 0 && legacyCount === 0) return
|
||||
scrollHelpers?.restoreAfterRender()
|
||||
onContentRendered?.()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="tool-call-task-sections">
|
||||
<Show when={promptContent()}>
|
||||
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={childToolKeys()}>
|
||||
<Index each={childToolKeys()}>
|
||||
{(key) => (
|
||||
<Show when={renderToolCall}>
|
||||
{(render) => (
|
||||
<TaskToolCallRow
|
||||
toolKey={key}
|
||||
toolKey={key()}
|
||||
store={store}
|
||||
sessionId={childSessionId()}
|
||||
renderToolCall={render()}
|
||||
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Index>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
|
||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||
restoreAfterRender(): void
|
||||
}
|
||||
|
||||
export interface ToolRendererContext {
|
||||
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
|
||||
forceCollapsed?: boolean
|
||||
}) => JSXElement | null
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
export interface ToolRenderer {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor,
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
|
||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
|
||||
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
||||
@@ -85,6 +87,28 @@ export interface VirtualFollowListProps<T> {
|
||||
*/
|
||||
followToken?: Accessor<string | number>
|
||||
|
||||
/**
|
||||
* Optional item key whose geometry can temporarily hold auto-follow when the
|
||||
* rendered item grows taller than the viewport and reaches the top edge.
|
||||
*/
|
||||
autoPinHoldTargetKey?: Accessor<string | null>
|
||||
|
||||
/**
|
||||
* Optional resolver for the specific element inside an item wrapper that
|
||||
* should be measured for hold-target geometry.
|
||||
*/
|
||||
resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined
|
||||
|
||||
/**
|
||||
* Top-edge threshold for the hold target in pixels.
|
||||
*/
|
||||
autoPinHoldTopThresholdPx?: number
|
||||
|
||||
/**
|
||||
* Temporarily suppress automatic bottom pinning while keeping follow mode enabled.
|
||||
*/
|
||||
suspendAutoPinToBottom?: Accessor<boolean>
|
||||
|
||||
/**
|
||||
* Optional hooks to render content inside the scroll container.
|
||||
* Useful for empty/loading states that should scroll with the list.
|
||||
@@ -130,13 +154,19 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
|
||||
const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false)
|
||||
const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null)
|
||||
const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX
|
||||
|
||||
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
||||
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
|
||||
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
|
||||
|
||||
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||
const itemElements = new Map<string, HTMLDivElement>()
|
||||
|
||||
let userScrollIntentUntil = 0
|
||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
||||
@@ -220,6 +250,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
|
||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||
if (hasUserScrollIntent()) {
|
||||
if (atBottom && heldItemCount() !== null) {
|
||||
setHeldItemCount(null)
|
||||
}
|
||||
if (atBottom && !autoScroll()) {
|
||||
setAutoScroll(true)
|
||||
} else if (!atBottom && autoScroll()) {
|
||||
@@ -253,6 +286,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
}
|
||||
updateScrollButtons()
|
||||
updateAutoPinHold()
|
||||
props.onScroll?.()
|
||||
|
||||
// Find active key (roughly the first visible item)
|
||||
@@ -270,6 +304,68 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
function registerItemElement(key: string, element: HTMLDivElement | null | undefined) {
|
||||
if (!element) {
|
||||
itemElements.delete(key)
|
||||
return
|
||||
}
|
||||
itemElements.set(key, element)
|
||||
}
|
||||
|
||||
function getAnchorIdForKey(key: string) {
|
||||
return props.getAnchorId ? props.getAnchorId(key) : key
|
||||
}
|
||||
|
||||
function updateAutoPinHold() {
|
||||
const element = scrollElement()
|
||||
const itemCount = props.items().length
|
||||
const heldCount = heldItemCount()
|
||||
if (!element) return
|
||||
|
||||
if (heldCount !== null) {
|
||||
if (itemCount > heldCount) {
|
||||
setHeldItemCount(null)
|
||||
if (autoScroll()) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!autoScroll()) return
|
||||
scrollToBottom(false)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (itemCount < heldCount) {
|
||||
setHeldItemCount(null)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!autoScroll()) return
|
||||
if (externalSuspendAutoPinToBottom()) return
|
||||
|
||||
const targetKey = holdTargetKey()
|
||||
if (!targetKey) return
|
||||
|
||||
const itemWrapper = itemElements.get(targetKey)
|
||||
if (!itemWrapper) return
|
||||
const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper
|
||||
|
||||
const containerRect = element.getBoundingClientRect()
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
const relativeTop = targetRect.top - containerRect.top
|
||||
const exceedsViewport = targetRect.height > element.clientHeight
|
||||
|
||||
if (
|
||||
exceedsViewport &&
|
||||
relativeTop <= holdTargetTopThresholdPx() &&
|
||||
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
|
||||
) {
|
||||
setHeldItemCount(itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
const api: VirtualFollowListApi = {
|
||||
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
||||
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||
@@ -281,7 +377,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
||||
},
|
||||
notifyContentRendered: () => {
|
||||
if (autoScroll()) {
|
||||
updateAutoPinHold()
|
||||
if (heldItemCount() !== null) return
|
||||
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
},
|
||||
@@ -294,9 +392,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
createEffect(() => props.registerApi?.(api))
|
||||
createEffect(() => props.registerState?.(state))
|
||||
|
||||
createEffect(on(() => props.resetKey?.(), () => {
|
||||
itemElements.clear()
|
||||
setHeldItemCount(null)
|
||||
}))
|
||||
|
||||
// Handle autoScroll (Follow) on items change
|
||||
createEffect(on(() => props.items().length, (len, prevLen) => {
|
||||
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
|
||||
updateAutoPinHold()
|
||||
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
|
||||
requestAnimationFrame(() => scrollToBottom(true))
|
||||
}
|
||||
suppressAutoScrollOnce = false
|
||||
@@ -304,11 +408,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
|
||||
// Handle followToken change
|
||||
createEffect(on(() => props.followToken?.(), () => {
|
||||
if (autoScroll()) {
|
||||
updateAutoPinHold()
|
||||
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
}, { defer: true }))
|
||||
|
||||
createEffect(on(() => holdTargetKey(), () => {
|
||||
updateAutoPinHold()
|
||||
}, { defer: true }))
|
||||
|
||||
// Reset state on resetKey change
|
||||
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
||||
if (nextKey === lastResetKey) return
|
||||
@@ -331,6 +440,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const handleResize = () => updateAutoPinHold()
|
||||
window.addEventListener("resize", handleResize)
|
||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="virtual-follow-list-shell" ref={shellElement => {
|
||||
setShellElement(shellElement)
|
||||
@@ -356,7 +472,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
bufferSize={props.overscanPx ?? 400}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{(item, index) => props.renderItem(item, index())}
|
||||
{(item, index) => {
|
||||
const key = props.getKey(item, index())
|
||||
const anchorId = getAnchorIdForKey(key)
|
||||
return (
|
||||
<div id={anchorId} data-virtual-follow-key={key} ref={(element) => registerItemElement(key, element)}>
|
||||
{props.renderItem(item, index())}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Virtualizer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -2,6 +2,7 @@ const HUNK_PATTERN = /(^|\n)@@/m
|
||||
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
|
||||
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
|
||||
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
|
||||
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
||||
|
||||
function stripCodeFence(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
@@ -48,3 +49,48 @@ export function isRenderableDiffText(raw?: string | null): raw is string {
|
||||
if (!normalized) return false
|
||||
return HUNK_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
export function parsePatchToBeforeAfter(patch: string): { before: string; after: string } {
|
||||
if (!patch || patch.trim().length === 0) {
|
||||
return { before: "", after: "" }
|
||||
}
|
||||
|
||||
const lines = patch.replace(/\r\n/g, "\n").split("\n")
|
||||
const beforeLines: string[] = []
|
||||
const afterLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff --git")) {
|
||||
continue
|
||||
}
|
||||
if (HUNK_HEADER_PATTERN.test(line)) {
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
beforeLines.push(line.slice(1))
|
||||
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
afterLines.push(line.slice(1))
|
||||
} else if (line.startsWith(" ")) {
|
||||
beforeLines.push(line.slice(1))
|
||||
afterLines.push(line.slice(1))
|
||||
} else if (line === "") {
|
||||
beforeLines.push("")
|
||||
afterLines.push("")
|
||||
} else {
|
||||
beforeLines.push(line)
|
||||
afterLines.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
while (beforeLines.length > 0 && beforeLines[beforeLines.length - 1] === "") {
|
||||
beforeLines.pop()
|
||||
}
|
||||
while (afterLines.length > 0 && afterLines[afterLines.length - 1] === "") {
|
||||
afterLines.pop()
|
||||
}
|
||||
|
||||
return {
|
||||
before: beforeLines.join("\n"),
|
||||
after: afterLines.join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
262
packages/ui/src/lib/follow-scroll.tsx
Normal file
262
packages/ui/src/lib/follow-scroll.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||
|
||||
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
||||
interface FollowScrollOptions {
|
||||
getScrollTopSnapshot: Accessor<number>
|
||||
setScrollTopSnapshot: (next: number) => void
|
||||
sentinelMarginPx: number
|
||||
sentinelClassName: string
|
||||
intentWindowMs?: number
|
||||
intentKeys?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export interface FollowScrollHelpers {
|
||||
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
||||
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
||||
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
||||
restoreAfterRender: () => void
|
||||
autoScroll: Accessor<boolean>
|
||||
}
|
||||
|
||||
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
|
||||
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
|
||||
let pendingScrollFrame: number | null = null
|
||||
let pendingAnchorScroll: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||
let pointerInteractionActive = false
|
||||
let suppressNextScrollHandling = false
|
||||
|
||||
function restoreScrollPosition(forceBottom = false) {
|
||||
const container = scrollContainerRef
|
||||
if (!container) return
|
||||
suppressNextScrollHandling = true
|
||||
if (forceBottom) {
|
||||
container.scrollTop = container.scrollHeight
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
} else {
|
||||
container.scrollTop = lastKnownScrollTop
|
||||
}
|
||||
}
|
||||
|
||||
function persistScrollSnapshot(element?: HTMLElement | null) {
|
||||
if (!element) return
|
||||
lastKnownScrollTop = element.scrollTop
|
||||
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
}
|
||||
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
if (pointerInteractionActive) {
|
||||
return true
|
||||
}
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement) {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
|
||||
const handlePointerIntent = () => {
|
||||
pointerInteractionActive = true
|
||||
markUserScrollIntent()
|
||||
}
|
||||
const clearPointerIntent = () => {
|
||||
pointerInteractionActive = false
|
||||
}
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (intentKeys.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("pointerup", clearPointerIntent)
|
||||
window.addEventListener("pointercancel", clearPointerIntent)
|
||||
window.addEventListener("mouseup", clearPointerIntent)
|
||||
window.addEventListener("touchend", clearPointerIntent)
|
||||
window.addEventListener("touchcancel", clearPointerIntent)
|
||||
}
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("pointerup", clearPointerIntent)
|
||||
window.removeEventListener("pointercancel", clearPointerIntent)
|
||||
window.removeEventListener("mouseup", clearPointerIntent)
|
||||
window.removeEventListener("touchend", clearPointerIntent)
|
||||
window.removeEventListener("touchcancel", clearPointerIntent)
|
||||
}
|
||||
pointerInteractionActive = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAnchorScroll(immediate = false) {
|
||||
if (!autoScroll()) return
|
||||
const sentinel = bottomSentinel()
|
||||
const container = scrollContainerRef
|
||||
if (!sentinel || !container) return
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||
pendingAnchorScroll = null
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const sentinelRect = sentinel.getBoundingClientRect()
|
||||
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
|
||||
if (Math.abs(delta) > 1) {
|
||||
suppressNextScrollHandling = true
|
||||
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
||||
}
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
})
|
||||
}
|
||||
|
||||
function isAtBottom(container: HTMLDivElement) {
|
||||
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
|
||||
}
|
||||
|
||||
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
|
||||
const container = containerOverride ?? scrollContainer()
|
||||
if (!container) return
|
||||
if (suppressNextScrollHandling) {
|
||||
suppressNextScrollHandling = false
|
||||
return
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
const atBottomFromScroll = isAtBottom(container)
|
||||
const atBottom = atBottomFromScroll || bottomSentinelVisible()
|
||||
|
||||
if (isUserScroll || !atBottom) {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
updateFollowModeFromScroll(event.currentTarget)
|
||||
persistScrollSnapshot(event.currentTarget)
|
||||
}
|
||||
|
||||
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
|
||||
const next = element || undefined
|
||||
if (next === scrollContainerRef) {
|
||||
return
|
||||
}
|
||||
scrollContainerRef = next
|
||||
setScrollContainer(scrollContainerRef)
|
||||
if (scrollContainerRef) {
|
||||
lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||
restoreScrollPosition(autoScroll())
|
||||
}
|
||||
}
|
||||
|
||||
const renderSentinel = (config?: { disableTracking?: boolean }) => {
|
||||
if (config?.disableTracking) return null
|
||||
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
||||
}
|
||||
|
||||
const restoreAfterRender = () => {
|
||||
const container = scrollContainerRef
|
||||
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||
if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition(false)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Never let a render-time caller force follow mode back on after the user
|
||||
// has already escaped it. Staying pinned should depend on the current
|
||||
// follow state, not on a caller opting into forceBottom.
|
||||
const shouldFollow = autoScroll()
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition(shouldFollow)
|
||||
if (shouldFollow) {
|
||||
scheduleAnchorScroll(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollContainer()
|
||||
if (!container) return
|
||||
attachScrollIntentListeners(container)
|
||||
onCleanup(() => {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollContainer()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.target === sentinel) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
pendingScrollFrame = null
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
registerContainer,
|
||||
handleScroll,
|
||||
renderSentinel,
|
||||
restoreAfterRender,
|
||||
autoScroll,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -160,6 +160,8 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.empty": "No background processes.",
|
||||
"instanceShell.backgroundProcesses.status": "Status: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
|
||||
"instanceShell.backgroundProcesses.notify.enabled": "Completion notification enabled",
|
||||
"instanceShell.backgroundProcesses.notify.disabled": "Completion notification disabled",
|
||||
"instanceShell.backgroundProcesses.actions.output": "Output",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "Stop",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminate",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
||||
"messageSection.loading.messages": "Loading messages...",
|
||||
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
|
||||
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
||||
"messageSection.scroll.enableHoldAriaLabel": "Enable hold for long assistant replies",
|
||||
"messageSection.scroll.disableHoldAriaLabel": "Disable hold for long assistant replies",
|
||||
"messageSection.quote.addAsQuote": "Add as quote",
|
||||
"messageSection.quote.addAsCode": "Add as code",
|
||||
"messageSection.quote.copy": "Copy",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -150,6 +150,8 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
|
||||
"instanceShell.backgroundProcesses.status": "Estado: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
|
||||
"instanceShell.backgroundProcesses.notify.enabled": "Notificacion de finalizacion activada",
|
||||
"instanceShell.backgroundProcesses.notify.disabled": "Notificacion de finalizacion desactivada",
|
||||
"instanceShell.backgroundProcesses.actions.output": "Salida",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "Detener",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
||||
"messageSection.loading.messages": "Cargando mensajes...",
|
||||
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
|
||||
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
||||
"messageSection.scroll.enableHoldAriaLabel": "Activar pausa para respuestas largas del asistente",
|
||||
"messageSection.scroll.disableHoldAriaLabel": "Desactivar pausa para respuestas largas del asistente",
|
||||
"messageSection.quote.addAsQuote": "Añadir como cita",
|
||||
"messageSection.quote.addAsCode": "Añadir como código",
|
||||
"messageSection.quote.copy": "Copiar",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user