Compare commits
65 Commits
v0.13.3
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,12 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
import { SpeechService } from "./speech/service"
|
||||
import { SideCarManager } from "./sidecars/manager"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -55,6 +56,7 @@ interface CliOptions {
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
authCookieName: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
@@ -100,6 +102,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||
.addOption(
|
||||
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
|
||||
.env("CODENOMAD_AUTH_COOKIE_NAME")
|
||||
.default(DEFAULT_AUTH_COOKIE_NAME),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
@@ -139,6 +146,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authCookieName: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
@@ -185,6 +193,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
authCookieName: parsed.authCookieName,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||
}
|
||||
@@ -266,6 +275,7 @@ async function main() {
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
cookieName: options.authCookieName,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
@@ -306,6 +316,11 @@ async function main() {
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||
const sidecarManager = new SideCarManager({
|
||||
settings,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "sidecars" }),
|
||||
})
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
@@ -391,6 +406,7 @@ async function main() {
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
@@ -412,6 +428,7 @@ async function main() {
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
@@ -442,18 +459,22 @@ async function main() {
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
let remoteHost = options.host
|
||||
if (wantsAll) {
|
||||
if (options.host === "0.0.0.0") {
|
||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteAddresses = resolved.userVisible
|
||||
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
if (!remoteUrl) {
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
@@ -464,7 +485,9 @@ async function main() {
|
||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||
|
||||
if (serverMeta.remotePort && remoteUrl) {
|
||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
serverMeta.addresses = remoteAddresses.length
|
||||
? remoteAddresses
|
||||
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
@@ -472,6 +495,16 @@ async function main() {
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (serverMeta.remoteUrl) {
|
||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||
const additionalRemoteUrls = serverMeta.addresses
|
||||
.map((addr) => addr.remoteUrl)
|
||||
.filter((url) => url !== serverMeta.remoteUrl)
|
||||
|
||||
if (additionalRemoteUrls.length > 0) {
|
||||
console.log("Other Accessible URLs:")
|
||||
for (const url of additionalRemoteUrls) {
|
||||
console.log(` - ${url}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.launch) {
|
||||
@@ -495,6 +528,12 @@ async function main() {
|
||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await sidecarManager.shutdown()
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "SideCar manager shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict"
|
||||
import os from "node:os"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
|
||||
|
||||
describe("resolveNetworkAddresses", () => {
|
||||
it("preserves interface order among external addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "10.0.0.8", family: 4, internal: false },
|
||||
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.map((entry) => entry.ip),
|
||||
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveRemoteAddresses", () => {
|
||||
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers private LAN addresses over public addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "8.8.8.8", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("uses a public address when no private LAN address is available", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
|
||||
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function usingMockedNetworkInterfaces(
|
||||
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
|
||||
callback: () => void,
|
||||
) {
|
||||
const original = os.networkInterfaces
|
||||
os.networkInterfaces = (() => ({
|
||||
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
|
||||
})) as typeof os.networkInterfaces
|
||||
|
||||
try {
|
||||
callback()
|
||||
} finally {
|
||||
os.networkInterfaces = original
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import cors from "@fastify/cors"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import fs from "fs"
|
||||
import { connect as connectTcp, type Socket } from "net"
|
||||
import path from "path"
|
||||
import { connect as connectTls, type TLSSocket } from "tls"
|
||||
import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
@@ -22,6 +24,8 @@ import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { registerSpeechRoutes } from "./routes/speech"
|
||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
@@ -32,6 +36,7 @@ import type { SpeechService } from "../speech/service"
|
||||
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||
import { PluginChannelManager } from "../plugins/channel"
|
||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||
import type { SideCarManager } from "../sidecars/manager"
|
||||
|
||||
interface HttpServerDeps {
|
||||
bindHost: string
|
||||
@@ -47,6 +52,7 @@ interface HttpServerDeps {
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
speechService: SpeechService
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
@@ -203,7 +209,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
|
||||
if (requiresAuthForApi && !session) {
|
||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||
@@ -270,7 +276,15 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||
setupSideCarWebSocketProxy(app, {
|
||||
sidecarManager: deps.sidecarManager,
|
||||
authManager: deps.authManager,
|
||||
logger: proxyLogger,
|
||||
})
|
||||
registerPluginRoutes(app, {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
@@ -353,6 +367,68 @@ interface InstanceProxyDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarProxyDeps {
|
||||
sidecarManager: SideCarManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
|
||||
authManager: AuthManager
|
||||
}
|
||||
|
||||
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
|
||||
const proxyBaseHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxySideCarRequest({
|
||||
request,
|
||||
reply,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
logger: deps.logger,
|
||||
pathSuffix: "",
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxySideCarRequest({
|
||||
request,
|
||||
reply,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
logger: deps.logger,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
app.all("/sidecars/:id", proxyBaseHandler)
|
||||
app.all("/sidecars/:id/*", proxyWildcardHandler)
|
||||
}
|
||||
|
||||
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
|
||||
app.server.on("upgrade", (request, socket, head) => {
|
||||
const rawUrl = request.url ?? "/"
|
||||
const parsed = parseSideCarUpgradePath(rawUrl)
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
void proxySideCarWebSocketUpgrade({
|
||||
request,
|
||||
socket: socket as Socket,
|
||||
head,
|
||||
sidecarId: parsed.sidecarId,
|
||||
incomingPath: parsed.pathname,
|
||||
search: parsed.search,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
authManager: deps.authManager,
|
||||
logger: deps.logger,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||
app.register(async (instance) => {
|
||||
instance.removeAllContentTypeParsers()
|
||||
@@ -837,3 +913,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function proxySideCarRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
sidecarManager: SideCarManager
|
||||
logger: Logger
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const sidecarId = (args.request.params as { id?: string }).id ?? ""
|
||||
const sidecar = await args.sidecarManager.get(sidecarId)
|
||||
if (!sidecar) {
|
||||
args.reply.code(404).send({ error: "SideCar not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
|
||||
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
|
||||
const pathSuffix = args.pathSuffix ?? ""
|
||||
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
|
||||
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
|
||||
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
|
||||
const targetUrl = `${targetOrigin}${targetPath}`
|
||||
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
|
||||
|
||||
await args.reply.from(targetUrl, {
|
||||
rewriteRequestHeaders: (_originalRequest, headers) =>
|
||||
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
|
||||
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
||||
onError: (reply, { error }) => {
|
||||
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send({ error: "SideCar proxy failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(rawUrl, "http://localhost")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
sidecarId: decodeURIComponent(match[1] ?? ""),
|
||||
pathname: parsed.pathname,
|
||||
search: parsed.search,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function proxySideCarWebSocketUpgrade(args: {
|
||||
request: import("http").IncomingMessage
|
||||
socket: Socket
|
||||
head: Buffer
|
||||
sidecarId: string
|
||||
incomingPath: string
|
||||
search: string
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
logger: Logger
|
||||
}) {
|
||||
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
|
||||
|
||||
if (!isWebSocketUpgradeRequest(request)) {
|
||||
rejectUpgrade(socket, 400, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
const session = authManager.getSessionFromHeaders(request.headers)
|
||||
if (!session) {
|
||||
rejectUpgrade(socket, 401, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
const sidecar = await sidecarManager.get(sidecarId)
|
||||
if (!sidecar) {
|
||||
rejectUpgrade(socket, 404, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
|
||||
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
|
||||
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
|
||||
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
|
||||
|
||||
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
|
||||
|
||||
const closeBoth = () => {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy()
|
||||
}
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
upstream.once("error", (error) => {
|
||||
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
|
||||
rejectUpgrade(socket, 502, "Bad Gateway")
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
socket.once("error", (error) => {
|
||||
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
upstream.once(readyEvent, () => {
|
||||
try {
|
||||
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
|
||||
if (head.length > 0) {
|
||||
upstream.write(head)
|
||||
}
|
||||
upstream.pipe(socket)
|
||||
socket.pipe(upstream)
|
||||
} catch (error) {
|
||||
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
|
||||
closeBoth()
|
||||
}
|
||||
})
|
||||
|
||||
upstream.once("close", () => {
|
||||
if (!socket.destroyed) {
|
||||
socket.end()
|
||||
}
|
||||
})
|
||||
|
||||
socket.once("close", () => {
|
||||
if (!upstream.destroyed) {
|
||||
upstream.end()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
|
||||
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
|
||||
if (targetUrl.protocol === "https:") {
|
||||
return {
|
||||
socket: connectTls({
|
||||
host: targetUrl.hostname,
|
||||
port,
|
||||
servername: targetUrl.hostname,
|
||||
}),
|
||||
readyEvent: "secureConnect",
|
||||
}
|
||||
}
|
||||
return {
|
||||
socket: connectTcp(port, targetUrl.hostname),
|
||||
readyEvent: "connect",
|
||||
}
|
||||
}
|
||||
|
||||
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
|
||||
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
|
||||
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
|
||||
const headerLines: string[] = []
|
||||
const rawHeaders = request.rawHeaders ?? []
|
||||
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||
|
||||
for (let index = 0; index < rawHeaders.length; index += 2) {
|
||||
const key = rawHeaders[index]
|
||||
const value = rawHeaders[index + 1]
|
||||
if (!key || value === undefined) continue
|
||||
const lower = key.toLowerCase()
|
||||
if (blockedHeaders.has(lower)) continue
|
||||
if (lower === "origin") {
|
||||
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
|
||||
continue
|
||||
}
|
||||
headerLines.push(`${key}: ${value}\r\n`)
|
||||
}
|
||||
|
||||
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
|
||||
headerLines.push(`Host: ${hostValue}\r\n`)
|
||||
headerLines.push("\r\n")
|
||||
|
||||
return requestLine + headerLines.join("")
|
||||
}
|
||||
|
||||
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
|
||||
const upgrade = request.headers.upgrade
|
||||
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
|
||||
return false
|
||||
}
|
||||
const connection = request.headers.connection
|
||||
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
|
||||
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
|
||||
if (socket.destroyed) {
|
||||
return
|
||||
}
|
||||
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||
socket.destroy()
|
||||
}
|
||||
|
||||
function rewriteSideCarResponseHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
sidecarId: string,
|
||||
targetOrigin: string,
|
||||
prefixMode: "strip" | "preserve",
|
||||
) {
|
||||
if (prefixMode === "preserve") {
|
||||
return headers
|
||||
}
|
||||
|
||||
const next = { ...headers }
|
||||
const locationHeader = next.location
|
||||
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
|
||||
if (!location) {
|
||||
return next
|
||||
}
|
||||
|
||||
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
|
||||
|
||||
if (location.startsWith("/")) {
|
||||
next.location = `${publicBase}${location}`
|
||||
return next
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(location)
|
||||
if (parsed.origin === targetOrigin) {
|
||||
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
}
|
||||
} catch {
|
||||
// Relative redirects should continue to resolve against the public sidecar path.
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function sanitizeSideCarProxyRequestHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
targetOrigin: string,
|
||||
): Record<string, string | string[] | undefined> {
|
||||
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||
const next: Record<string, string | string[] | undefined> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!value) continue
|
||||
if (blockedHeaders.has(key.toLowerCase())) continue
|
||||
next[key] = value
|
||||
}
|
||||
|
||||
next.origin = targetOrigin
|
||||
return next
|
||||
}
|
||||
|
||||
function getBlockedSideCarRequestHeaders(): Set<string> {
|
||||
return new Set([
|
||||
"host",
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import os from "os"
|
||||
import type { NetworkAddress } from "../api-types"
|
||||
|
||||
export interface ResolvedRemoteAddresses {
|
||||
all: NetworkAddress[]
|
||||
userVisible: NetworkAddress[]
|
||||
primaryRemoteUrl?: string
|
||||
}
|
||||
|
||||
export function resolveNetworkAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRemoteAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
port: number
|
||||
}): ResolvedRemoteAddresses {
|
||||
const all = resolveNetworkAddresses(args)
|
||||
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
|
||||
return {
|
||||
all,
|
||||
userVisible,
|
||||
primaryRemoteUrl: userVisible[0]?.remoteUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
|
||||
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
|
||||
}
|
||||
|
||||
function getUserVisiblePriority(ip: string): number {
|
||||
if (isPrivateIPv4(ip)) return 0
|
||||
if (isLinkLocalIPv4(ip)) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function isLinkLocalIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
return first === 169 && second === 254
|
||||
}
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
|
||||
if (first === 10) return true
|
||||
if (first === 192 && second === 168) return true
|
||||
return first === 172 && second >= 16 && second <= 31
|
||||
}
|
||||
|
||||
function parseIPv4(value: string): number[] | null {
|
||||
if (!isIPv4Address(value)) return null
|
||||
return value.split(".").map((part) => Number(part))
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -2378,6 +2378,72 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -48,7 +48,7 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
#[cfg(windows)]
|
||||
@@ -124,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||
fn exchange_bootstrap_token(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
cookie_name: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||
@@ -159,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
for line in lines {
|
||||
// handle case-insensitive header name
|
||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
}
|
||||
@@ -172,11 +176,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||
fn set_session_cookie(
|
||||
app: &AppHandle,
|
||||
base_url: &str,
|
||||
cookie_name: &str,
|
||||
session_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||
|
||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
||||
.domain(domain)
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
@@ -190,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_auth_cookie_name() -> String {
|
||||
let pid = std::process::id();
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
|
||||
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -503,7 +522,8 @@ impl CliProcessManager {
|
||||
"resolved CLI entry runner={:?} entry={} host={}",
|
||||
resolution.runner, resolution.entry, host
|
||||
));
|
||||
let args = resolution.build_args(dev, &host);
|
||||
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
||||
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
@@ -514,7 +534,9 @@ impl CliProcessManager {
|
||||
log_line(&format!("using cwd={}", c.display()));
|
||||
}
|
||||
|
||||
let command_info = if supports_user_shell() {
|
||||
let use_user_shell = supports_user_shell();
|
||||
|
||||
let command_info = if use_user_shell {
|
||||
log_line("spawning via user shell");
|
||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||
} else {
|
||||
@@ -525,7 +547,7 @@ impl CliProcessManager {
|
||||
})
|
||||
};
|
||||
|
||||
if !supports_user_shell() {
|
||||
if !use_user_shell {
|
||||
if which::which(&resolution.node_binary).is_err() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Node binary not found. Make sure Node.js is installed."
|
||||
@@ -539,6 +561,8 @@ impl CliProcessManager {
|
||||
let mut c = Command::new(&cmd.shell);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.env_remove("npm_config_prefix")
|
||||
.env_remove("NPM_CONFIG_PREFIX")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
@@ -584,6 +608,7 @@ impl CliProcessManager {
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let token_clone = bootstrap_token.clone();
|
||||
let auth_cookie_name_clone = auth_cookie_name.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let stdout = child_clone
|
||||
@@ -598,24 +623,41 @@ impl CliProcessManager {
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -731,10 +773,10 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
loop {
|
||||
@@ -766,39 +808,17 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.map(|m| m.as_str().to_string())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
auth_cookie_name,
|
||||
url,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.to_lowercase().contains("http server listening") {
|
||||
if let Some(port) = http_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{port}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{}", port),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -811,6 +831,7 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
base_url: String,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
@@ -834,9 +855,11 @@ impl CliProcessManager {
|
||||
if scheme.as_deref() != Some("http") {
|
||||
navigate_main(app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
if let Err(err) =
|
||||
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
||||
{
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
@@ -932,12 +955,15 @@ impl CliEntry {
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--auth-cookie-name".to_string(),
|
||||
auth_cookie_name.to_string(),
|
||||
"--generate-token".to_string(),
|
||||
"--unrestricted-root".to_string(),
|
||||
];
|
||||
|
||||
if dev {
|
||||
@@ -993,27 +1019,50 @@ impl CliEntry {
|
||||
}
|
||||
|
||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let mut candidates = vec![
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
std::env::current_exe().ok().and_then(|ex| {
|
||||
ex.parent()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
||||
}),
|
||||
];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
|
||||
}
|
||||
}
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("packages/server/src/index.ts")),
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
@@ -1115,11 +1164,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if shell_name.contains("zsh") {
|
||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||
} else {
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
let _ = shell_name;
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
|
||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||
|
||||
@@ -6,13 +6,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>() {
|
||||
|
||||
@@ -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,17 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const nextText = props.text()
|
||||
if (preRef && preRef.textContent !== nextText) {
|
||||
preRef.textContent = nextText
|
||||
}
|
||||
if (followScroll.autoScroll()) {
|
||||
followScroll.restoreAfterRender({ forceBottom: true })
|
||||
}
|
||||
notifyContentRendered()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingRenderNotificationFrame !== null) {
|
||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||
@@ -1319,6 +1357,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 +1462,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 +1616,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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,33 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? 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 +156,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 +344,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[]>([])
|
||||
@@ -615,7 +638,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 +757,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 +914,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 +1030,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
data-scroll-buttons={scrollButtonsCount()}
|
||||
>
|
||||
<VirtualFollowList
|
||||
items={messageIds}
|
||||
items={visibleMessageIds}
|
||||
getKey={(messageId) => messageId}
|
||||
getAnchorId={getMessageAnchorId}
|
||||
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
||||
@@ -1035,7 +1076,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
registerState={(state) => setListState(state)}
|
||||
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">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untra
|
||||
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"
|
||||
@@ -105,6 +106,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
|
||||
}
|
||||
|
||||
@@ -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 && !after.startsWith("\n") ? "\n" : ""
|
||||
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>
|
||||
|
||||
@@ -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({ forceBottom: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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(options?: { forceBottom?: boolean }): 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()
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
259
packages/ui/src/lib/follow-scroll.tsx
Normal file
259
packages/ui/src/lib/follow-scroll.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
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: (options?: { forceBottom?: boolean }) => 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 = (config?: { forceBottom?: boolean }) => {
|
||||
const container = scrollContainerRef
|
||||
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||
if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition(false)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const shouldFollow = config?.forceBottom ?? 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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
|
||||
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
||||
|
||||
"commands.closeInstance.label": "Fermer l'instance",
|
||||
"commands.closeInstance.description": "Arrêter le serveur de l'instance actuelle",
|
||||
"commands.closeInstance.keywords": "arrêter, quitter, fermer",
|
||||
"commands.closeInstance.label": "Fermer l'onglet",
|
||||
"commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel",
|
||||
"commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet",
|
||||
|
||||
"commands.nextInstance.label": "Instance suivante",
|
||||
"commands.nextInstance.description": "Passer à l'onglet d'instance suivant",
|
||||
"commands.nextInstance.keywords": "changer, naviguer, suivant",
|
||||
"commands.nextInstance.label": "Onglet suivant",
|
||||
"commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant",
|
||||
"commands.nextInstance.keywords": "changer, naviguer, suivant, onglet",
|
||||
|
||||
"commands.previousInstance.label": "Instance précédente",
|
||||
"commands.previousInstance.description": "Passer à l'onglet d'instance précédent",
|
||||
"commands.previousInstance.keywords": "changer, naviguer, précédent",
|
||||
"commands.previousInstance.label": "Onglet précédent",
|
||||
"commands.previousInstance.description": "Passer à l'onglet de premier niveau précédent",
|
||||
"commands.previousInstance.keywords": "changer, naviguer, précédent, onglet",
|
||||
|
||||
"commands.newSession.label": "Nouvelle session",
|
||||
"commands.newSession.description": "Créer une nouvelle session parente",
|
||||
|
||||
@@ -5,7 +5,7 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
|
||||
|
||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
||||
"folderSelection.links.githubStars": "Stars GitHub de CodeNomad",
|
||||
"folderSelection.links.githubStars": "Étoiles GitHub de CodeNomad",
|
||||
"folderSelection.links.discord": "Discord de CodeNomad",
|
||||
|
||||
"folderSelection.empty.title": "Aucun dossier récent",
|
||||
@@ -16,10 +16,13 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
|
||||
"folderSelection.recent.remove": "Retirer des récents",
|
||||
|
||||
"folderSelection.browse.title": "Parcourir les dossiers",
|
||||
"folderSelection.browse.title": "Parcourir un dossier",
|
||||
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
||||
"folderSelection.browse.button": "Parcourir les dossiers",
|
||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
||||
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
|
||||
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
|
||||
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
|
||||
|
||||
"folderSelection.advancedSettings": "Paramètres avancés",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
||||
|
||||
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||
|
||||
"folderSelection.tabs.local": "Dossiers locaux",
|
||||
"folderSelection.tabs.servers": "Serveurs",
|
||||
"folderSelection.servers.title": "Serveurs enregistrés",
|
||||
"folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre",
|
||||
"folderSelection.servers.count": "{count} serveurs",
|
||||
"folderSelection.servers.empty.title": "Aucun serveur enregistré",
|
||||
"folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil",
|
||||
"folderSelection.servers.connectTitle": "Se connecter à un serveur",
|
||||
"folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre",
|
||||
"folderSelection.servers.connectButton": "Se connecter à un serveur",
|
||||
"folderSelection.servers.remove": "Supprimer le serveur enregistré",
|
||||
"folderSelection.servers.skipTls": "TLS auto-signé",
|
||||
"folderSelection.servers.errorTitle": "Échec de la connexion distante",
|
||||
"folderSelection.servers.dialog.title": "Se connecter à un serveur",
|
||||
"folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.",
|
||||
"folderSelection.servers.dialog.name": "Nom du serveur",
|
||||
"folderSelection.servers.dialog.namePlaceholder": "Serveur de production",
|
||||
"folderSelection.servers.dialog.url": "URL du serveur",
|
||||
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||
"folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.",
|
||||
"folderSelection.servers.dialog.cancel": "Annuler",
|
||||
"folderSelection.servers.dialog.save": "Enregistrer",
|
||||
"folderSelection.servers.dialog.connect": "Se connecter",
|
||||
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -150,6 +150,8 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
|
||||
"instanceShell.backgroundProcesses.status": "Statut : {status}",
|
||||
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
|
||||
"instanceShell.backgroundProcesses.notify.enabled": "Notification de fin activee",
|
||||
"instanceShell.backgroundProcesses.notify.disabled": "Notification de fin desactivee",
|
||||
"instanceShell.backgroundProcesses.actions.output": "Sortie",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
|
||||
"remoteAccess.addresses.loading": "Chargement des adresses…",
|
||||
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
|
||||
"remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses",
|
||||
"remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses",
|
||||
"remoteAccess.address.scope.network": "Réseau",
|
||||
"remoteAccess.address.scope.loopback": "Boucle locale",
|
||||
"remoteAccess.address.scope.internal": "Interne",
|
||||
|
||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||
"settings.opencode.runtime.title": "Runtime",
|
||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||
"settings.opencode.logLevel.title": "Niveau de logs OpenCode",
|
||||
"settings.opencode.logLevel.subtitle": "Definir le niveau de logs utilise au lancement des nouvelles instances OpenCode.",
|
||||
"settings.opencode.logLevel.selector.title": "Verbosite des logs",
|
||||
"settings.opencode.logLevel.selector.subtitle": "Choisir la quantite de journaux emise par les nouvelles instances OpenCode.",
|
||||
"settings.opencode.logLevel.option.debug": "Debogage",
|
||||
"settings.opencode.logLevel.option.info": "Info",
|
||||
"settings.opencode.logLevel.option.warn": "Avertissement",
|
||||
"settings.opencode.logLevel.option.error": "Erreur",
|
||||
|
||||
"settings.appearance.behavior.title": "Interaction",
|
||||
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
|
||||
@@ -186,4 +194,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "Enregistré",
|
||||
"settings.speech.save.unsaved": "Modifications non enregistrées",
|
||||
"settings.speech.save.error": "Échec de l'enregistrement",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
||||
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
||||
"toolCall.diff.viewMode.split": "Côte à côte",
|
||||
"toolCall.diff.viewMode.unified": "Unifié",
|
||||
"toolCall.diff.switchToSplit": "Passer à la vue côte à côte",
|
||||
"toolCall.diff.switchToUnified": "Passer à la vue unifiée",
|
||||
"toolCall.diff.enableWordWrap": "Activer le retour à la ligne",
|
||||
"toolCall.diff.disableWordWrap": "Désactiver le retour à la ligne",
|
||||
"toolCall.diff.copyPatch": "Copier le patch",
|
||||
|
||||
"toolCall.diagnostics.title": "Diagnostics",
|
||||
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
||||
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
||||
|
||||
"commands.closeInstance.label": "סגור מופע",
|
||||
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
|
||||
"commands.closeInstance.keywords": "עצור, סגור",
|
||||
"commands.closeInstance.label": "סגור לשונית",
|
||||
"commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית",
|
||||
"commands.closeInstance.keywords": "עצור, סגור, לשונית",
|
||||
|
||||
"commands.nextInstance.label": "מופע הבא",
|
||||
"commands.nextInstance.description": "עבור למופע הבא",
|
||||
"commands.nextInstance.keywords": "החלף, נווט",
|
||||
"commands.nextInstance.label": "הלשונית הבאה",
|
||||
"commands.nextInstance.description": "עבור ללשונית העליונה הבאה",
|
||||
"commands.nextInstance.keywords": "החלף, נווט, לשונית",
|
||||
|
||||
"commands.previousInstance.label": "מופע קודם",
|
||||
"commands.previousInstance.description": "עבור למופע הקודם",
|
||||
"commands.previousInstance.keywords": "החלף, נווט",
|
||||
"commands.previousInstance.label": "הלשונית הקודמת",
|
||||
"commands.previousInstance.description": "עבור ללשונית העליונה הקודמת",
|
||||
"commands.previousInstance.keywords": "החלף, נווט, לשונית",
|
||||
|
||||
"commands.newSession.label": "סשן חדש",
|
||||
"commands.newSession.description": "צור סשן הורה חדש",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
|
||||
"folderSelection.browse.button": "עיון בתיקיות",
|
||||
"folderSelection.browse.buttonOpening": "פותח...",
|
||||
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
|
||||
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
|
||||
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
|
||||
|
||||
"folderSelection.advancedSettings": "הגדרות מתקדמות",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
||||
|
||||
"folderSelection.dialog.title": "בחר סביבת עבודה",
|
||||
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
|
||||
|
||||
"folderSelection.tabs.local": "תיקיות מקומיות",
|
||||
"folderSelection.tabs.servers": "שרתים",
|
||||
"folderSelection.servers.title": "שרתים שמורים",
|
||||
"folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש",
|
||||
"folderSelection.servers.count": "{count} שרתים",
|
||||
"folderSelection.servers.empty.title": "אין שרתים שמורים",
|
||||
"folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה",
|
||||
"folderSelection.servers.connectTitle": "התחבר לשרת",
|
||||
"folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש",
|
||||
"folderSelection.servers.connectButton": "התחבר לשרת",
|
||||
"folderSelection.servers.remove": "הסר שרת שמור",
|
||||
"folderSelection.servers.skipTls": "TLS בחתימה עצמית",
|
||||
"folderSelection.servers.errorTitle": "החיבור המרוחק נכשל",
|
||||
"folderSelection.servers.dialog.title": "התחבר לשרת",
|
||||
"folderSelection.servers.dialog.description": "הוסף שרת CodeNomad מרוחק ופתח אותו מיד אם תרצה.",
|
||||
"folderSelection.servers.dialog.name": "שם השרת",
|
||||
"folderSelection.servers.dialog.namePlaceholder": "שרת ייצור",
|
||||
"folderSelection.servers.dialog.url": "כתובת השרת",
|
||||
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||
"folderSelection.servers.dialog.skipTls": "דלג על אימות TLS עבור תעודות בחתימה עצמית.",
|
||||
"folderSelection.servers.dialog.cancel": "ביטול",
|
||||
"folderSelection.servers.dialog.save": "שמור",
|
||||
"folderSelection.servers.dialog.connect": "התחבר",
|
||||
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
||||
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
||||
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -158,6 +158,8 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
|
||||
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
|
||||
"instanceShell.backgroundProcesses.notify.enabled": "התראת סיום פעילה",
|
||||
"instanceShell.backgroundProcesses.notify.disabled": "התראת סיום כבויה",
|
||||
"instanceShell.backgroundProcesses.actions.output": "פלט",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "עצור",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
||||
"remoteAccess.addresses.loading": "טוען כתובות…",
|
||||
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
||||
"remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות",
|
||||
"remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות",
|
||||
"remoteAccess.address.scope.network": "רשת",
|
||||
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
||||
"remoteAccess.address.scope.internal": "פנימי",
|
||||
|
||||
@@ -112,6 +112,14 @@ export const settingsMessages = {
|
||||
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
|
||||
"settings.opencode.runtime.title": "סביבת ריצה",
|
||||
"settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.",
|
||||
"settings.opencode.logLevel.title": "רמת הלוגים של OpenCode",
|
||||
"settings.opencode.logLevel.subtitle": "הגדר את רמת הלוגים שבה ייעשה שימוש בעת הפעלת מופעי OpenCode חדשים.",
|
||||
"settings.opencode.logLevel.selector.title": "פירוט לוגים",
|
||||
"settings.opencode.logLevel.selector.subtitle": "בחר כמה לוגים מופעי OpenCode חדשים צריכים להפיק.",
|
||||
"settings.opencode.logLevel.option.debug": "ניפוי שגיאות",
|
||||
"settings.opencode.logLevel.option.info": "מידע",
|
||||
"settings.opencode.logLevel.option.warn": "אזהרה",
|
||||
"settings.opencode.logLevel.option.error": "שגיאה",
|
||||
|
||||
"settings.appearance.behavior.title": "אינטראקציה",
|
||||
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
|
||||
@@ -185,4 +193,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "נשמר",
|
||||
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
|
||||
"settings.speech.save.error": "השמירה נכשלה",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
||||
"toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff",
|
||||
"toolCall.diff.viewMode.split": "מפוצל",
|
||||
"toolCall.diff.viewMode.unified": "מאוחד",
|
||||
"toolCall.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
||||
"toolCall.diff.switchToUnified": "עבור לתצוגה מאוחדת",
|
||||
"toolCall.diff.enableWordWrap": "הפעל גלישת מילים",
|
||||
"toolCall.diff.disableWordWrap": "כבה גלישת מילים",
|
||||
"toolCall.diff.copyPatch": "העתק patch",
|
||||
|
||||
"toolCall.diagnostics.title": "אבחון",
|
||||
"toolCall.diagnostics.ariaLabel": "אבחון",
|
||||
|
||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
||||
"commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成",
|
||||
"commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace",
|
||||
|
||||
"commands.closeInstance.label": "インスタンスを閉じる",
|
||||
"commands.closeInstance.description": "現在のインスタンスのサーバーを停止",
|
||||
"commands.closeInstance.keywords": "停止, 終了, 閉じる, stop, quit, close",
|
||||
"commands.closeInstance.label": "タブを閉じる",
|
||||
"commands.closeInstance.description": "現在のトップレベルタブを閉じる",
|
||||
"commands.closeInstance.keywords": "閉じる, タブ, stop, quit, close",
|
||||
|
||||
"commands.nextInstance.label": "次のインスタンス",
|
||||
"commands.nextInstance.description": "次のインスタンスタブへ切り替え",
|
||||
"commands.nextInstance.keywords": "切り替え, 移動, switch, navigate",
|
||||
"commands.nextInstance.label": "次のタブ",
|
||||
"commands.nextInstance.description": "次のトップレベルタブへ切り替え",
|
||||
"commands.nextInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
|
||||
|
||||
"commands.previousInstance.label": "前のインスタンス",
|
||||
"commands.previousInstance.description": "前のインスタンスタブへ切り替え",
|
||||
"commands.previousInstance.keywords": "切り替え, 移動, switch, navigate",
|
||||
"commands.previousInstance.label": "前のタブ",
|
||||
"commands.previousInstance.description": "前のトップレベルタブへ切り替え",
|
||||
"commands.previousInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
|
||||
|
||||
"commands.newSession.label": "新しいセッション",
|
||||
"commands.newSession.description": "新しい親セッションを作成",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.subtitle": "コンピュータ上の任意のフォルダを選択",
|
||||
"folderSelection.browse.button": "フォルダを参照",
|
||||
"folderSelection.browse.buttonOpening": "開いています...",
|
||||
"folderSelection.actions.title": "フォルダを開くかサーバーに接続",
|
||||
"folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します",
|
||||
"folderSelection.actions.connectButton": "CodeNomad サーバーに接続",
|
||||
|
||||
"folderSelection.advancedSettings": "詳細設定",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
||||
|
||||
"folderSelection.dialog.title": "ワークスペースを選択",
|
||||
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
||||
|
||||
"folderSelection.tabs.local": "ローカルフォルダ",
|
||||
"folderSelection.tabs.servers": "サーバー",
|
||||
"folderSelection.servers.title": "保存済みサーバー",
|
||||
"folderSelection.servers.subtitle": "保存したリモート CodeNomad サーバーを新しいウィンドウで開きます",
|
||||
"folderSelection.servers.count": "{count} サーバー",
|
||||
"folderSelection.servers.empty.title": "保存済みサーバーはありません",
|
||||
"folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーを追加してください",
|
||||
"folderSelection.servers.connectTitle": "サーバーに接続",
|
||||
"folderSelection.servers.connectSubtitle": "リモート CodeNomad サーバーを保存して新しいウィンドウで開きます",
|
||||
"folderSelection.servers.connectButton": "サーバーに接続",
|
||||
"folderSelection.servers.remove": "保存したサーバーを削除",
|
||||
"folderSelection.servers.skipTls": "自己署名 TLS",
|
||||
"folderSelection.servers.errorTitle": "リモート接続に失敗しました",
|
||||
"folderSelection.servers.dialog.title": "サーバーに接続",
|
||||
"folderSelection.servers.dialog.description": "リモート CodeNomad サーバーを追加し、必要に応じてすぐに開きます。",
|
||||
"folderSelection.servers.dialog.name": "サーバー名",
|
||||
"folderSelection.servers.dialog.namePlaceholder": "本番サーバー",
|
||||
"folderSelection.servers.dialog.url": "サーバー URL",
|
||||
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||
"folderSelection.servers.dialog.skipTls": "自己署名証明書の TLS 検証をスキップします。",
|
||||
"folderSelection.servers.dialog.cancel": "キャンセル",
|
||||
"folderSelection.servers.dialog.save": "保存",
|
||||
"folderSelection.servers.dialog.connect": "接続",
|
||||
"folderSelection.servers.dialog.connecting": "接続中...",
|
||||
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
||||
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -150,6 +150,8 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。",
|
||||
"instanceShell.backgroundProcesses.status": "状態: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB",
|
||||
"instanceShell.backgroundProcesses.notify.enabled": "完了通知が有効",
|
||||
"instanceShell.backgroundProcesses.notify.disabled": "完了通知が無効",
|
||||
"instanceShell.backgroundProcesses.actions.output": "出力",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "停止",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "終了",
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。",
|
||||
"remoteAccess.addresses.loading": "アドレスを読み込み中…",
|
||||
"remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。",
|
||||
"remoteAccess.addresses.actions.showOther": "他の {count} 件のアドレスを表示",
|
||||
"remoteAccess.addresses.actions.hideOther": "他のアドレスを隠す",
|
||||
"remoteAccess.address.scope.network": "ネットワーク",
|
||||
"remoteAccess.address.scope.loopback": "ループバック",
|
||||
"remoteAccess.address.scope.internal": "内部",
|
||||
|
||||
@@ -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": "OpenCode のログレベル",
|
||||
"settings.opencode.logLevel.subtitle": "新しい OpenCode インスタンスの起動時に使うログレベルを設定します。",
|
||||
"settings.opencode.logLevel.selector.title": "ログ出力の詳細度",
|
||||
"settings.opencode.logLevel.selector.subtitle": "新しい OpenCode インスタンスがどの程度ログを出力するかを選択します。",
|
||||
"settings.opencode.logLevel.option.debug": "デバッグ",
|
||||
"settings.opencode.logLevel.option.info": "情報",
|
||||
"settings.opencode.logLevel.option.warn": "警告",
|
||||
"settings.opencode.logLevel.option.error": "エラー",
|
||||
|
||||
"settings.appearance.behavior.title": "操作",
|
||||
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
|
||||
@@ -186,4 +194,40 @@ export const settingsMessages = {
|
||||
"settings.speech.save.saved": "保存済み",
|
||||
"settings.speech.save.unsaved": "未保存の変更",
|
||||
"settings.speech.save.error": "保存に失敗しました",
|
||||
"settings.nav.sidecars": "SideCars",
|
||||
"settings.section.sidecars.eyebrow": "Server services",
|
||||
"settings.section.sidecars.title": "SideCars",
|
||||
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||
"sidecars.form.name": "Name",
|
||||
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||
"sidecars.form.port": "Port",
|
||||
"sidecars.form.insecure": "Use HTTP",
|
||||
"sidecars.form.protocol": "Protocol",
|
||||
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||
"sidecars.form.protocol.https": "HTTPS",
|
||||
"sidecars.form.protocol.http": "HTTP",
|
||||
"sidecars.form.prefixMode": "Prefix mode",
|
||||
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||
"sidecars.form.add": "Add SideCar",
|
||||
"sidecars.kind.port": "Port",
|
||||
"sidecars.status.running": "Running",
|
||||
"sidecars.status.stopped": "Stopped",
|
||||
"sidecars.basePath": "Base path",
|
||||
"sidecars.settings.listTitle": "Configured SideCars",
|
||||
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||
"sidecars.picker.title": "Open SideCar",
|
||||
"sidecars.picker.loading": "Loading SideCars...",
|
||||
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||
"sidecars.picker.close": "Close",
|
||||
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||
"sidecars.open.notFound": "SideCar not found.",
|
||||
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||
"sidecars.back": "Back",
|
||||
"sidecars.refresh": "Refresh",
|
||||
"sidecars.path": "Path",
|
||||
"sidecars.go": "Go",
|
||||
} as const
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user