Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcbe3475ed | ||
|
|
338a88fb5a | ||
|
|
7eb1551e4b | ||
|
|
0414f924e6 | ||
|
|
9456871271 | ||
|
|
5b4edef785 | ||
|
|
6b81d0d703 | ||
|
|
4097637169 | ||
|
|
9bd66e7297 | ||
|
|
883b0724e0 | ||
|
|
7b6ed88be4 | ||
|
|
e0bb867948 | ||
|
|
ca28f503b7 | ||
|
|
c83028abc2 | ||
|
|
60406ca8fb | ||
|
|
e878c3c83b | ||
|
|
bdd3fe8899 | ||
|
|
3cfaf689e7 | ||
|
|
b41da03e8a | ||
|
|
ef14b9acb6 | ||
|
|
99474955af | ||
|
|
6f73adaef6 | ||
|
|
e2ff758003 | ||
|
|
748a99c9c4 | ||
|
|
db2d764cce | ||
|
|
157fe9d6b4 | ||
|
|
6c42b64466 | ||
|
|
88605a4617 | ||
|
|
e8f8e7bd65 | ||
|
|
750a87ef45 | ||
|
|
8fda9aed71 | ||
|
|
7e1dab8384 | ||
|
|
5b24f0cd40 | ||
|
|
a6b1f4ba19 | ||
|
|
df02b7cdca | ||
|
|
06b0d03c31 | ||
|
|
fd22a5ed9d | ||
|
|
86db407c0b | ||
|
|
f1520be777 | ||
|
|
8a91e04ff9 | ||
|
|
76b1134c95 | ||
|
|
d98d519fd3 | ||
|
|
02407e0f7a | ||
|
|
0261154a5e | ||
|
|
d2b68159be | ||
|
|
aab0692403 | ||
|
|
17a3e43ac7 | ||
|
|
a2127a11ac | ||
|
|
ea4c687125 | ||
|
|
de20b3adf3 | ||
|
|
929e79befd | ||
|
|
3522d3dff5 | ||
|
|
1af01680ee |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Neural Nomads
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
4667
package-lock.json
generated
4667
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.3",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/server",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@codenomad/ui-host-worker",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:manifest": "node ./scripts/build-manifest.mjs",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.9.2",
|
||||
"minServerVersion": "0.10.1",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
||||
import { BrowserWindow, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
|
||||
interface DialogOpenRequest {
|
||||
mode: "directory" | "file"
|
||||
title?: string
|
||||
@@ -62,4 +64,31 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
|
||||
return { canceled: result.canceled, paths: result.filePaths }
|
||||
})
|
||||
|
||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) {
|
||||
return { enabled: true }
|
||||
}
|
||||
try {
|
||||
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
|
||||
} catch {
|
||||
wakeLockId = null
|
||||
return { enabled: false }
|
||||
}
|
||||
return { enabled: true }
|
||||
}
|
||||
|
||||
if (wakeLockId !== null) {
|
||||
try {
|
||||
if (powerSaveBlocker.isStarted(wakeLockId)) {
|
||||
powerSaveBlocker.stop(wakeLockId)
|
||||
}
|
||||
} finally {
|
||||
wakeLockId = null
|
||||
}
|
||||
}
|
||||
return { enabled: false }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -505,7 +505,6 @@ app.on("before-quit", async (event) => {
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
// CodeNomad supports a single window; closing it should quit the app on all platforms.
|
||||
app.quit()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
@@ -82,6 +82,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
@@ -91,6 +92,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
@@ -109,11 +111,13 @@ export class CliProcessManager extends EventEmitter {
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
@@ -175,12 +179,89 @@ export class CliProcessManager extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
// Negative PID targets the process group (POSIX).
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryKillSinglePid = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryTaskkill = (force: boolean) => {
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync("taskkill", args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the PID is already gone, treat it as success.
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("not found") || combined.includes("no running instance")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron.
|
||||
const groupOk = tryKillPosixGroup(signal)
|
||||
if (!groupOk) {
|
||||
tryKillSinglePid(signal)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||
)
|
||||
child.kill("SIGKILL")
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
@@ -191,7 +272,15 @@ export class CliProcessManager extends EventEmitter {
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.kill("SIGTERM")
|
||||
if (isAlreadyExited()) {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
sendStopSignal("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,7 +294,16 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
this.child.kill("SIGKILL")
|
||||
const pid = this.child.pid
|
||||
if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
} catch {
|
||||
this.child.kill("SIGKILL")
|
||||
}
|
||||
} else {
|
||||
this.child.kill("SIGKILL")
|
||||
}
|
||||
this.child = undefined
|
||||
}
|
||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||
@@ -249,38 +347,27 @@ export class CliProcessManager extends EventEmitter {
|
||||
console.info(`[cli][${stream}] ${trimmed}`)
|
||||
this.emit("log", { stream, message: trimmed })
|
||||
|
||||
const port = this.extractPort(trimmed)
|
||||
if (port && this.status.state === "starting") {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
console.info(`[cli] ready on ${url}`)
|
||||
this.updateStatus({ state: "ready", port, url })
|
||||
const localUrl = this.extractLocalUrl(trimmed)
|
||||
if (localUrl && this.status.state === "starting") {
|
||||
let port: number | undefined
|
||||
try {
|
||||
port = Number(new URL(localUrl).port) || undefined
|
||||
} catch {
|
||||
port = undefined
|
||||
}
|
||||
console.info(`[cli] ready on ${localUrl}`)
|
||||
this.updateStatus({ state: "ready", port, url: localUrl })
|
||||
this.emit("ready", this.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractPort(line: string): number | null {
|
||||
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
|
||||
if (readyMatch) {
|
||||
return parseInt(readyMatch[1], 10)
|
||||
private extractLocalUrl(line: string): string | null {
|
||||
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (line.toLowerCase().includes("http server listening")) {
|
||||
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
|
||||
if (httpMatch) {
|
||||
return parseInt(httpMatch[1], 10)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (typeof parsed.port === "number") {
|
||||
return parsed.port
|
||||
}
|
||||
} catch {
|
||||
// not JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return match[1] ?? null
|
||||
}
|
||||
|
||||
private updateStatus(patch: Partial<CliStatus>) {
|
||||
@@ -289,7 +376,15 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
|
||||
const args = ["serve", "--host", host, "--generate-token"]
|
||||
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
args.push("--https", "false", "--http", "true")
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https", "true", "--http", "true")
|
||||
}
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
|
||||
@@ -12,6 +12,7 @@ const electronAPI = {
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.3",
|
||||
"version": "0.10.1",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "@codenomad/opencode-config",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.36"
|
||||
"@opencode-ai/plugin": "1.1.53"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ You can run CodeNomad directly without installing it:
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
On startup, CodeNomad prints two URLs:
|
||||
|
||||
- `Local Connection URL : ...` (used by desktop shells)
|
||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||
|
||||
### Install Globally
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
@@ -44,7 +49,14 @@ You can configure the server using flags or environment variables:
|
||||
|
||||
| Flag | Env Variable | Description |
|
||||
|------|--------------|-------------|
|
||||
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
|
||||
| `--https <enabled>` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) |
|
||||
| `--http <enabled>` | `CLI_HTTP` | Enable HTTP listener (default `false`) |
|
||||
| `--https-port <number>` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) |
|
||||
| `--http-port <number>` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) |
|
||||
| `--tls-key <path>` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. |
|
||||
| `--tls-cert <path>` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. |
|
||||
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||
@@ -56,12 +68,59 @@ You can configure the server using flags or environment variables:
|
||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||
|
||||
### HTTP vs HTTPS
|
||||
|
||||
- Default: `--https=true --http=false` (HTTPS only).
|
||||
- To run plain HTTP only (useful for development):
|
||||
|
||||
```sh
|
||||
codenomad --https=false --http=true
|
||||
```
|
||||
|
||||
- To run both HTTPS (for remote) and HTTP loopback (for desktop):
|
||||
|
||||
```sh
|
||||
codenomad --https=true --http=true
|
||||
```
|
||||
|
||||
### Remote Access Binding Rules
|
||||
|
||||
- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`):
|
||||
- HTTP listens on `127.0.0.1` only.
|
||||
- HTTPS listens on `--host` (LAN/all interfaces).
|
||||
- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`):
|
||||
- Both HTTP and HTTPS listen on `127.0.0.1`.
|
||||
|
||||
### Self-Signed Certificates
|
||||
|
||||
If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory:
|
||||
|
||||
- `~/.config/codenomad/tls/ca-cert.pem`
|
||||
- `~/.config/codenomad/tls/server-cert.pem`
|
||||
|
||||
Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via:
|
||||
|
||||
```sh
|
||||
codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
||||
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
||||
|
||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||
2. Click the install icon in the address bar, or use the browser menu → "Install CodeNomad".
|
||||
3. The app will open in a standalone window and appear in your OS app list.
|
||||
|
||||
> **TLS requirement**
|
||||
> Browsers require a secure (`https://`) connection for PWA installation.
|
||||
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||
|
||||
### Data Storage
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.3",
|
||||
"version": "0.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.3",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.3",
|
||||
"version": "0.10.1",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
@@ -20,7 +21,7 @@
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -30,12 +31,14 @@
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -50,6 +50,38 @@ export interface WorkspaceDeleteResponse {
|
||||
status: WorkspaceStatus
|
||||
}
|
||||
|
||||
export type WorktreeKind = "root" | "worktree"
|
||||
|
||||
export interface WorktreeDescriptor {
|
||||
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
|
||||
slug: string
|
||||
/** Absolute directory path on the server host. */
|
||||
directory: string
|
||||
kind: WorktreeKind
|
||||
/** Optional VCS branch name when available. */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreeListResponse {
|
||||
worktrees: WorktreeDescriptor[]
|
||||
/** True when the workspace folder resolves to a Git repository. */
|
||||
isGitRepo?: boolean
|
||||
}
|
||||
|
||||
export interface WorktreeCreateRequest {
|
||||
slug: string
|
||||
/** Optional branch name (defaults to slug). */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreeMap {
|
||||
version: 1
|
||||
/** Default worktree to use for new sessions and as fallback. */
|
||||
defaultWorktreeSlug: string
|
||||
/** Mapping of *parent* session IDs to a worktree slug. */
|
||||
parentSessionWorktreeSlug: Record<string, string>
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||
|
||||
export interface WorkspaceLogEntry {
|
||||
@@ -204,7 +236,8 @@ export interface NetworkAddress {
|
||||
ip: string
|
||||
family: "ipv4" | "ipv6"
|
||||
scope: "external" | "internal" | "loopback"
|
||||
url: string
|
||||
/** Remote URL using the server's remote protocol/port for this IP. */
|
||||
remoteUrl: string
|
||||
}
|
||||
|
||||
export interface LatestReleaseInfo {
|
||||
@@ -230,16 +263,20 @@ export interface SupportMeta {
|
||||
}
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
httpBaseUrl: string
|
||||
/** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */
|
||||
localUrl: string
|
||||
/** URL remote clients should use (prefers HTTPS when enabled). */
|
||||
remoteUrl?: string
|
||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||
eventsUrl: string
|
||||
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||
host: string
|
||||
/** Listening mode derived from host binding. */
|
||||
listeningMode: "local" | "all"
|
||||
/** Actual port in use after binding. */
|
||||
port: number
|
||||
/** Actual local port in use after binding. */
|
||||
localPort: number
|
||||
/** Actual remote port in use after binding (when remoteUrl is set). */
|
||||
remotePort?: number
|
||||
/** Display label for the host (e.g., hostname or friendly name). */
|
||||
hostLabel: string
|
||||
/** Absolute path of the filesystem root exposed to clients. */
|
||||
|
||||
@@ -119,10 +119,18 @@ export class AuthManager {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
||||
}
|
||||
|
||||
setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options))
|
||||
}
|
||||
|
||||
clearSessionCookie(reply: FastifyReply) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||
}
|
||||
|
||||
clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options }))
|
||||
}
|
||||
|
||||
private requireAuthStore(): AuthStore {
|
||||
if (!this.authStore) {
|
||||
throw new Error("Auth store is unavailable")
|
||||
@@ -143,8 +151,11 @@ function resolvePath(filePath: string) {
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
|
||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) {
|
||||
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
||||
if (options?.secure) {
|
||||
parts.push("Secure")
|
||||
}
|
||||
if (options?.maxAgeSeconds !== undefined) {
|
||||
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const PreferencesSchema = z.object({
|
||||
showThinkingBlocks: z.boolean().default(false),
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showTimelineTools: z.boolean().default(true),
|
||||
promptSubmitOnEnter: z.boolean().default(false),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
|
||||
@@ -222,20 +222,18 @@ export class FileSystemBrowser {
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
for (const entry of dirents) {
|
||||
if (!options.includeFiles && !entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const absoluteEntryPath = path.join(directory, entry.name)
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
|
||||
// are treated as directories in directory-only listings.
|
||||
stats = fs.statSync(absoluteEntryPath)
|
||||
} catch {
|
||||
// Skip entries we cannot stat (insufficient permissions, etc.)
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = entry.isDirectory()
|
||||
const isDirectory = stats.isDirectory()
|
||||
if (!options.includeFiles && !isDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ 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 { resolveHttpsOptions } from "./server/tls"
|
||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -28,8 +30,15 @@ const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
|
||||
interface CliOptions {
|
||||
port: number
|
||||
host: string
|
||||
https: boolean
|
||||
http: boolean
|
||||
httpsPort: number
|
||||
httpPort: number
|
||||
tlsKeyPath?: string
|
||||
tlsCertPath?: string
|
||||
tlsCaPath?: string
|
||||
tlsSANs?: string
|
||||
rootDir: string
|
||||
configPath: string
|
||||
unrestrictedRoot: boolean
|
||||
@@ -47,9 +56,10 @@ interface CliOptions {
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
const DEFAULT_HOST = "127.0.0.1"
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
const DEFAULT_HTTPS_PORT = 9898
|
||||
const DEFAULT_HTTP_PORT = 9899
|
||||
|
||||
function parseCliOptions(argv: string[]): CliOptions {
|
||||
const program = new Command()
|
||||
@@ -57,7 +67,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.description("CodeNomad CLI server")
|
||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--https <enabled>", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true"))
|
||||
.addOption(new Option("--http <enabled>", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false"))
|
||||
.addOption(new Option("--https-port <number>", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--http-port <number>", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--tls-key <path>", "TLS private key (PEM)").env("CLI_TLS_KEY"))
|
||||
.addOption(new Option("--tls-cert <path>", "TLS certificate (PEM)").env("CLI_TLS_CERT"))
|
||||
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||
.addOption(
|
||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
)
|
||||
@@ -97,7 +114,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
host: string
|
||||
port: number
|
||||
https?: string
|
||||
http?: string
|
||||
httpsPort: number
|
||||
httpPort: number
|
||||
tlsKey?: string
|
||||
tlsCert?: string
|
||||
tlsCa?: string
|
||||
tlsSANs?: string
|
||||
workspaceRoot?: string
|
||||
root?: string
|
||||
unrestrictedRoot?: boolean
|
||||
@@ -128,9 +152,23 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
|
||||
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
|
||||
|
||||
const httpsEnabled = parseBooleanEnv(parsed.https)
|
||||
const httpEnabled = parseBooleanEnv(parsed.http)
|
||||
|
||||
if (!httpsEnabled && !httpEnabled) {
|
||||
throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)")
|
||||
}
|
||||
|
||||
return {
|
||||
port: parsed.port,
|
||||
host: normalizedHost,
|
||||
https: httpsEnabled,
|
||||
http: httpEnabled,
|
||||
httpsPort: parsed.httpsPort,
|
||||
httpPort: parsed.httpPort,
|
||||
tlsKeyPath: parsed.tlsKey,
|
||||
tlsCertPath: parsed.tlsCert,
|
||||
tlsCaPath: parsed.tlsCa,
|
||||
tlsSANs: parsed.tlsSANs,
|
||||
rootDir: resolvedRoot,
|
||||
configPath: parsed.config,
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
@@ -172,6 +210,13 @@ function resolveHost(input: string | undefined): string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
function programHasArg(argv: string[], flag: string): boolean {
|
||||
return argv.includes(flag)
|
||||
}
|
||||
@@ -200,12 +245,20 @@ async function main() {
|
||||
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const configDir = path.dirname(resolvePath(options.configPath))
|
||||
|
||||
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
||||
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
||||
}
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
localUrl: "http://localhost:0",
|
||||
remoteUrl: undefined,
|
||||
eventsUrl: `/api/events`,
|
||||
host: options.host,
|
||||
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||
port: options.port,
|
||||
localPort: 0,
|
||||
remotePort: undefined,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
addresses: [],
|
||||
@@ -229,6 +282,19 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const tlsResolution = resolveHttpsOptions({
|
||||
enabled: options.https,
|
||||
configDir,
|
||||
host: options.host,
|
||||
tlsKeyPath: options.tlsKeyPath,
|
||||
tlsCertPath: options.tlsCertPath,
|
||||
tlsCaPath: options.tlsCaPath,
|
||||
tlsSANs: options.tlsSANs,
|
||||
logger: logger.child({ component: "tls" }),
|
||||
})
|
||||
|
||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
@@ -237,7 +303,8 @@ async function main() {
|
||||
binaryRegistry,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
getServerBaseUrl: () => serverMeta.httpBaseUrl,
|
||||
getServerBaseUrl: () => serverMeta.localUrl,
|
||||
nodeExtraCaCertsPath,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
@@ -277,28 +344,121 @@ async function main() {
|
||||
minServerVersion: uiResolution.minServerVersion,
|
||||
}
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
})
|
||||
if (uiResolution.uiDevServerUrl && options.https) {
|
||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
||||
}
|
||||
|
||||
const startInfo = await server.start()
|
||||
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
|
||||
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
||||
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||
|
||||
const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0
|
||||
const httpBindPort = httpPortExplicit ? options.httpPort : 0
|
||||
|
||||
// Listener binding rules:
|
||||
// - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP).
|
||||
// - Remote access disabled: both listen on loopback.
|
||||
// - HTTP-only mode: respect --host (used for dev/testing).
|
||||
const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1"
|
||||
const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"
|
||||
|
||||
const servers: Array<ReturnType<typeof createHttpServer>> = []
|
||||
|
||||
const httpServer = options.http
|
||||
? createHttpServer({
|
||||
bindHost: httpBindHost,
|
||||
bindPort: httpBindPort,
|
||||
defaultPort: options.httpPort,
|
||||
protocol: "http",
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
})
|
||||
: null
|
||||
|
||||
const httpsServer = options.https
|
||||
? createHttpServer({
|
||||
bindHost: httpsBindHost,
|
||||
bindPort: httpsBindPort,
|
||||
defaultPort: options.httpsPort,
|
||||
protocol: "https",
|
||||
httpsOptions: tlsResolution?.httpsOptions,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
})
|
||||
: null
|
||||
|
||||
if (httpServer) servers.push(httpServer)
|
||||
if (httpsServer) servers.push(httpsServer)
|
||||
|
||||
const [httpStart, httpsStart] = await Promise.all([
|
||||
httpServer ? httpServer.start() : Promise.resolve(null),
|
||||
httpsServer ? httpsServer.start() : Promise.resolve(null),
|
||||
])
|
||||
|
||||
const localStart = httpStart ?? httpsStart
|
||||
if (!localStart) {
|
||||
throw new Error("No listeners started")
|
||||
}
|
||||
|
||||
const remoteStart = httpsStart ?? httpStart
|
||||
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
|
||||
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
|
||||
|
||||
const localUrl = `${localProtocol}://localhost:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
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"
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
serverMeta.localPort = localStart.port
|
||||
serverMeta.remoteUrl = remoteUrl
|
||||
serverMeta.remotePort = remoteStart?.port
|
||||
serverMeta.host = options.host
|
||||
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 })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (serverMeta.remoteUrl) {
|
||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||
}
|
||||
|
||||
if (options.launch) {
|
||||
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
|
||||
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
@@ -328,8 +488,8 @@ async function main() {
|
||||
|
||||
const shutdownHttp = (async () => {
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
await Promise.allSettled(servers.map((srv) => srv.stop()))
|
||||
logger.info("HTTP server(s) stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "path"
|
||||
import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
@@ -20,6 +21,7 @@ import { registerEventRoutes } from "./routes/events"
|
||||
import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
@@ -28,8 +30,12 @@ import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
port: number
|
||||
bindHost: string
|
||||
bindPort: number
|
||||
/** When bindPort is 0, try this first. */
|
||||
defaultPort: number
|
||||
protocol: "http" | "https"
|
||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
workspaceManager: WorkspaceManager
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
@@ -49,10 +55,15 @@ interface HttpServerStartResult {
|
||||
displayHost: string
|
||||
}
|
||||
|
||||
const DEFAULT_HTTP_PORT = 9898
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
|
||||
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
|
||||
const app = Fastify(
|
||||
({
|
||||
logger: false,
|
||||
...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}),
|
||||
} as unknown) as any,
|
||||
) as unknown as FastifyInstance
|
||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||
const apiLogger = deps.logger.child({ component: "http" })
|
||||
const sseLogger = deps.logger.child({ component: "sse" })
|
||||
@@ -95,6 +106,27 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const getSelfOrigins = (): Set<string> => {
|
||||
const origins = new Set<string>()
|
||||
const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
try {
|
||||
origins.add(new URL(candidate).origin)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
for (const addr of deps.serverMeta.addresses ?? []) {
|
||||
try {
|
||||
origins.add(new URL(addr.remoteUrl).origin)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
app.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
if (!origin) {
|
||||
@@ -102,14 +134,8 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
let selfOrigin: string | null = null
|
||||
try {
|
||||
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
|
||||
} catch {
|
||||
selfOrigin = null
|
||||
}
|
||||
|
||||
if (selfOrigin && origin === selfOrigin) {
|
||||
const selfOrigins = getSelfOrigins()
|
||||
if (selfOrigins.has(origin)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
@@ -120,7 +146,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
}
|
||||
|
||||
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
||||
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
|
||||
if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
@@ -222,6 +248,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
@@ -242,12 +269,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
instance: app,
|
||||
start: async (): Promise<HttpServerStartResult> => {
|
||||
const attemptListen = async (requestedPort: number) => {
|
||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
|
||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
|
||||
return { addressInfo, requestedPort }
|
||||
}
|
||||
|
||||
const autoPortRequested = deps.port === 0
|
||||
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
|
||||
const autoPortRequested = deps.bindPort === 0
|
||||
const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
|
||||
|
||||
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||
if (!autoPortRequested) return false
|
||||
@@ -283,15 +310,10 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
|
||||
const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.serverMeta.host = deps.host
|
||||
deps.serverMeta.port = actualPort
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
|
||||
|
||||
return { port: actualPort, url: serverUrl, displayHost }
|
||||
},
|
||||
@@ -312,31 +334,36 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
||||
instance.removeAllContentTypeParsers()
|
||||
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||
|
||||
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
const proxyBaseHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
worktreeSlug: request.params.slug,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
worktreeSlug: request.params.slug,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
||||
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,12 +374,75 @@ async function proxyWorkspaceRequest(args: {
|
||||
reply: FastifyReply
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
worktreeSlug: string
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const { request, reply, workspaceManager, logger } = args
|
||||
const { request, reply, workspaceManager, logger, worktreeSlug } = args
|
||||
const workspaceId = (request.params as { id: string }).id
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
|
||||
const bodyToJson = (body: unknown): unknown => {
|
||||
if (body == null) return null
|
||||
|
||||
const anyBody = body as any
|
||||
if (anyBody && typeof anyBody.pipe === "function") {
|
||||
// Don't consume streams (would break proxying).
|
||||
// Best-effort: if the stream already has buffered chunks, parse those.
|
||||
try {
|
||||
const buffered = anyBody?._readableState?.buffer
|
||||
if (Array.isArray(buffered) && buffered.length > 0) {
|
||||
const chunks: Buffer[] = []
|
||||
for (const entry of buffered) {
|
||||
if (!entry) continue
|
||||
if (Buffer.isBuffer(entry)) {
|
||||
chunks.push(entry)
|
||||
continue
|
||||
}
|
||||
const data = (entry as any).data
|
||||
if (Buffer.isBuffer(data)) {
|
||||
chunks.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const text = Buffer.concat(chunks).toString("utf-8")
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { __raw: text }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return { __stream: true }
|
||||
}
|
||||
|
||||
const maybeParse = (input: string): unknown => {
|
||||
try {
|
||||
return JSON.parse(input)
|
||||
} catch {
|
||||
return { __raw: input }
|
||||
}
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(body)) {
|
||||
return maybeParse(body.toString("utf-8"))
|
||||
}
|
||||
|
||||
if (typeof body === "string") {
|
||||
return maybeParse(body)
|
||||
}
|
||||
|
||||
if (typeof body === "object") {
|
||||
return body
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
@@ -364,6 +454,23 @@ async function proxyWorkspaceRequest(args: {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidWorktreeSlug(worktreeSlug)) {
|
||||
reply.code(400).send({ error: "Invalid worktree slug" })
|
||||
return
|
||||
}
|
||||
|
||||
const directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
@@ -381,15 +488,42 @@ async function proxyWorkspaceRequest(args: {
|
||||
headers.authorization = instanceAuthHeader
|
||||
}
|
||||
|
||||
// Enforce per-workspace directory scoping for all proxied OpenCode requests.
|
||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
||||
const directory = workspace.path
|
||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
||||
|
||||
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
||||
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
const outgoing: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
outgoing[key] = value
|
||||
}
|
||||
|
||||
// Redact sensitive headers.
|
||||
for (const key of Object.keys(outgoing)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
||||
outgoing[key] = "<redacted>"
|
||||
}
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
{
|
||||
workspaceId,
|
||||
method: request.method,
|
||||
targetUrl,
|
||||
worktreeSlug,
|
||||
directory,
|
||||
contentType: request.headers["content-type"],
|
||||
body: bodyToJson(request.body),
|
||||
headers: outgoing,
|
||||
},
|
||||
"Proxy -> OpenCode request",
|
||||
)
|
||||
}
|
||||
|
||||
return headers
|
||||
},
|
||||
onError: (proxyReply, { error }) => {
|
||||
@@ -409,6 +543,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||
}
|
||||
|
||||
type WorktreeCacheEntry = {
|
||||
expiresAt: number
|
||||
repoRoot: string
|
||||
worktrees: Array<{ slug: string; directory: string }>
|
||||
}
|
||||
|
||||
const WORKTREE_CACHE_TTL_MS = 2000
|
||||
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
||||
|
||||
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
|
||||
const cached = worktreeCache.get(params.workspaceId)
|
||||
const now = Date.now()
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
||||
const entry: WorktreeCacheEntry = {
|
||||
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
||||
repoRoot,
|
||||
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
|
||||
}
|
||||
worktreeCache.set(params.workspaceId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
async function resolveWorktreeDirectory(params: {
|
||||
workspaceId: string
|
||||
workspacePath: string
|
||||
worktreeSlug: string
|
||||
logger: Logger
|
||||
}): Promise<string | null> {
|
||||
const { worktreeSlug } = params
|
||||
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
|
||||
if (match) {
|
||||
return match.directory
|
||||
}
|
||||
|
||||
// If the slug is new (e.g., created moments ago), refresh once.
|
||||
worktreeCache.delete(params.workspaceId)
|
||||
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
|
||||
75
packages/server/src/server/network-addresses.ts
Normal file
75
packages/server/src/server/network-addresses.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import os from "os"
|
||||
import type { NetworkAddress } from "../api-types"
|
||||
|
||||
export function resolveNetworkAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
port: number
|
||||
}): NetworkAddress[] {
|
||||
const { host, protocol, port } = args
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const results: NetworkAddress[] = []
|
||||
|
||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||
if (!ip || ip === "0.0.0.0") return
|
||||
const key = `ipv4-${ip}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` })
|
||||
}
|
||||
|
||||
const normalizeFamily = (value: string | number) => {
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === "ipv4") {
|
||||
return "ipv4" as const
|
||||
}
|
||||
}
|
||||
if (value === 4) return "ipv4" as const
|
||||
return null
|
||||
}
|
||||
|
||||
if (host === "0.0.0.0") {
|
||||
// Enumerate system interfaces (IPv4 only)
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
const family = normalizeFamily(entry.family)
|
||||
if (!family) continue
|
||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||
addAddress(entry.address, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include loopback address
|
||||
addAddress("127.0.0.1", "loopback")
|
||||
|
||||
// Include explicitly configured host if it was IPv4
|
||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||
const isLoopback = host.startsWith("127.")
|
||||
addAddress(host, isLoopback ? "loopback" : "external")
|
||||
}
|
||||
|
||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
})
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const session = deps.authManager.createSession(body.username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -112,12 +112,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
const username = deps.authManager.getStatus().username
|
||||
const session = deps.authManager.createSession(username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/api/auth/logout", async (_request, reply) => {
|
||||
deps.authManager.clearSessionCookie(reply)
|
||||
app.post("/api/auth/logout", async (request, reply) => {
|
||||
deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) })
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -139,6 +139,13 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
function isSecureRequest(request: any) {
|
||||
if (request.protocol === "https") {
|
||||
return true
|
||||
}
|
||||
return Boolean(request.raw?.socket && request.raw.socket.encrypted)
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"]/g, (char) => {
|
||||
switch (char) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import os from "os"
|
||||
import { NetworkAddress, ServerMeta } from "../../api-types"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
import { resolveNetworkAddresses } from "../network-addresses"
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
@@ -11,23 +11,25 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const port = resolvePort(meta)
|
||||
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
|
||||
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,
|
||||
port,
|
||||
localPort,
|
||||
remotePort: remote?.port,
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePort(meta: ServerMeta): number {
|
||||
if (Number.isInteger(meta.port) && meta.port > 0) {
|
||||
return meta.port
|
||||
function resolveLocalPort(meta: ServerMeta): number {
|
||||
if (Number.isInteger(meta.localPort) && meta.localPort > 0) {
|
||||
return meta.localPort
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(meta.httpBaseUrl)
|
||||
const parsed = new URL(meta.localUrl)
|
||||
const port = Number(parsed.port)
|
||||
return Number.isInteger(port) && port > 0 ? port : 0
|
||||
} catch {
|
||||
@@ -35,74 +37,22 @@ function resolvePort(meta: ServerMeta): number {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null {
|
||||
if (!meta.remoteUrl) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(meta.remoteUrl)
|
||||
const protocol = parsed.protocol === "https:" ? "https" : "http"
|
||||
const port = Number(parsed.port)
|
||||
return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
}
|
||||
|
||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const results: NetworkAddress[] = []
|
||||
|
||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||
if (!ip || ip === "0.0.0.0") return
|
||||
const key = `ipv4-${ip}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
|
||||
}
|
||||
|
||||
const normalizeFamily = (value: string | number) => {
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === "ipv4") {
|
||||
return "ipv4" as const
|
||||
}
|
||||
}
|
||||
if (value === 4) return "ipv4" as const
|
||||
return null
|
||||
}
|
||||
|
||||
if (host === "0.0.0.0") {
|
||||
// Enumerate system interfaces (IPv4 only)
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
const family = normalizeFamily(entry.family)
|
||||
if (!family) continue
|
||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||
addAddress(entry.address, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include loopback address
|
||||
addAddress("127.0.0.1", "loopback")
|
||||
|
||||
// Include explicitly configured host if it was IPv4
|
||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||
const isLoopback = host.startsWith("127.")
|
||||
addAddress(host, isLoopback ? "loopback" : "external")
|
||||
}
|
||||
|
||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
})
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
// NetworkAddress shape is resolved in ../network-addresses
|
||||
|
||||
195
packages/server/src/server/routes/worktrees.ts
Normal file
195
packages/server/src/server/routes/worktrees.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
import {
|
||||
resolveRepoRoot,
|
||||
listWorktrees,
|
||||
isValidWorktreeSlug,
|
||||
createManagedWorktree,
|
||||
removeWorktree,
|
||||
} from "../../workspaces/git-worktrees"
|
||||
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
|
||||
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
}
|
||||
|
||||
const WorktreeMapSchema = z.object({
|
||||
version: z.literal(1),
|
||||
defaultWorktreeSlug: z.string().min(1).default("root"),
|
||||
parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}),
|
||||
})
|
||||
|
||||
const WorktreeCreateSchema = z.object({
|
||||
slug: z.string().trim().min(1),
|
||||
branch: z.string().trim().min(1).optional(),
|
||||
})
|
||||
|
||||
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||
const response: WorktreeListResponse = { worktrees, isGitRepo }
|
||||
return response
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = WorktreeCreateSchema.parse(request.body ?? {})
|
||||
const slug = body.slug
|
||||
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree slug" }
|
||||
}
|
||||
if (body.branch) {
|
||||
if (!isValidWorktreeSlug(body.branch) || body.branch === "root") {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree branch" }
|
||||
}
|
||||
if (body.branch !== slug) {
|
||||
reply.code(400)
|
||||
return { error: "Branch must match slug" }
|
||||
}
|
||||
}
|
||||
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||
if (!isGitRepo) {
|
||||
reply.code(400)
|
||||
return { error: "Workspace is not a Git repository" }
|
||||
}
|
||||
|
||||
await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined)
|
||||
|
||||
const created = await createManagedWorktree({
|
||||
repoRoot,
|
||||
workspaceFolder: workspace.path,
|
||||
slug,
|
||||
logger: request.log,
|
||||
})
|
||||
|
||||
reply.code(201)
|
||||
return created
|
||||
} catch (error) {
|
||||
return handleError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>(
|
||||
"/api/workspaces/:id/worktrees/:slug",
|
||||
async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
const slug = (request.params.slug ?? "").trim()
|
||||
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree slug" }
|
||||
}
|
||||
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||
if (!isGitRepo) {
|
||||
reply.code(400)
|
||||
return { error: "Workspace is not a Git repository" }
|
||||
}
|
||||
|
||||
const force = (request.query?.force ?? "").toString().toLowerCase() === "true"
|
||||
|
||||
try {
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||
const match = worktrees.find((wt) => wt.slug === slug)
|
||||
if (!match || match.kind === "root") {
|
||||
reply.code(404)
|
||||
return { error: "Worktree not found" }
|
||||
}
|
||||
|
||||
await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log })
|
||||
|
||||
// Best-effort: prune any mappings that point at the deleted worktree.
|
||||
const current = await readWorktreeMap(workspace.path, request.log)
|
||||
let changed = false
|
||||
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||
for (const [sessionId, mapped] of Object.entries(nextMapping)) {
|
||||
if (mapped === slug) {
|
||||
delete nextMapping[sessionId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug
|
||||
if (nextDefault !== current.defaultWorktreeSlug) {
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
await writeWorktreeMap(
|
||||
workspace.path,
|
||||
{
|
||||
version: 1,
|
||||
defaultWorktreeSlug: nextDefault,
|
||||
parentSessionWorktreeSlug: nextMapping,
|
||||
},
|
||||
request.log,
|
||||
)
|
||||
}
|
||||
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleError(error, reply)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
return await readWorktreeMap(workspace.path, request.log)
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap
|
||||
if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) {
|
||||
reply.code(400)
|
||||
return { error: "Invalid defaultWorktreeSlug" }
|
||||
}
|
||||
for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) {
|
||||
if (!isValidWorktreeSlug(slug)) {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree slug in mapping" }
|
||||
}
|
||||
}
|
||||
await writeWorktreeMap(workspace.path, parsed, request.log)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleError(error, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleError(error: unknown, reply: FastifyReply) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
|
||||
}
|
||||
283
packages/server/src/server/tls.ts
Normal file
283
packages/server/src/server/tls.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import crypto from "crypto"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { createRequire } from "module"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
type Forge = typeof import("node-forge")
|
||||
|
||||
function loadForge(): Forge {
|
||||
// node-forge is CJS in many installs; require keeps this compatible with our ESM output.
|
||||
return require("node-forge") as Forge
|
||||
}
|
||||
|
||||
export interface ResolvedHttpsOptions {
|
||||
httpsOptions: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
/** Path to CA certificate suitable for NODE_EXTRA_CA_CERTS. */
|
||||
caCertPath?: string
|
||||
mode: "provided" | "generated"
|
||||
}
|
||||
|
||||
export interface ResolveHttpsOptionsArgs {
|
||||
enabled: boolean
|
||||
configDir: string
|
||||
host: string
|
||||
tlsKeyPath?: string
|
||||
tlsCertPath?: string
|
||||
tlsCaPath?: string
|
||||
tlsSANs?: string
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const LEAF_VALIDITY_DAYS = 30
|
||||
const ROTATE_IF_EXPIRES_WITHIN_DAYS = 3
|
||||
|
||||
const CA_VALIDITY_DAYS = 365
|
||||
|
||||
export function resolveHttpsOptions(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions | null {
|
||||
if (!args.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasProvided = Boolean(args.tlsKeyPath && args.tlsCertPath)
|
||||
if (hasProvided) {
|
||||
const key = fs.readFileSync(args.tlsKeyPath!, "utf-8")
|
||||
const cert = fs.readFileSync(args.tlsCertPath!, "utf-8")
|
||||
const ca = args.tlsCaPath ? fs.readFileSync(args.tlsCaPath, "utf-8") : undefined
|
||||
return {
|
||||
httpsOptions: { key, cert, ca },
|
||||
caCertPath: args.tlsCaPath,
|
||||
mode: "provided",
|
||||
}
|
||||
}
|
||||
|
||||
return ensureGeneratedTls(args)
|
||||
}
|
||||
|
||||
function ensureGeneratedTls(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions {
|
||||
const tlsDir = path.join(args.configDir, "tls")
|
||||
const caKeyPath = path.join(tlsDir, "ca-key.pem")
|
||||
const caCertPath = path.join(tlsDir, "ca-cert.pem")
|
||||
const keyPath = path.join(tlsDir, "server-key.pem")
|
||||
const certPath = path.join(tlsDir, "server-cert.pem")
|
||||
|
||||
fs.mkdirSync(tlsDir, { recursive: true })
|
||||
|
||||
const shouldRotateLeaf = () => {
|
||||
try {
|
||||
if (!fs.existsSync(certPath)) return true
|
||||
const pem = fs.readFileSync(certPath, "utf-8")
|
||||
const x509 = new crypto.X509Certificate(pem)
|
||||
const validToMs = Date.parse(x509.validTo)
|
||||
if (!Number.isFinite(validToMs)) return true
|
||||
const rotateAt = validToMs - ROTATE_IF_EXPIRES_WITHIN_DAYS * 24 * 60 * 60 * 1000
|
||||
return Date.now() >= rotateAt
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRotateCa = () => {
|
||||
try {
|
||||
if (!fs.existsSync(caCertPath)) return true
|
||||
const pem = fs.readFileSync(caCertPath, "utf-8")
|
||||
const x509 = new crypto.X509Certificate(pem)
|
||||
const validToMs = Date.parse(x509.validTo)
|
||||
if (!Number.isFinite(validToMs)) return true
|
||||
// CA rotates only when expired.
|
||||
return Date.now() >= validToMs
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRotateCa() || !fs.existsSync(caKeyPath)) {
|
||||
const { caKeyPem, caCertPem } = generateCaCertificate()
|
||||
writePemFile(caKeyPath, caKeyPem, 0o600)
|
||||
writePemFile(caCertPath, caCertPem, 0o644)
|
||||
args.logger.info({ caCertPath }, "Generated self-signed CodeNomad CA certificate")
|
||||
}
|
||||
|
||||
if (shouldRotateLeaf() || !fs.existsSync(keyPath)) {
|
||||
const caKeyPem = fs.readFileSync(caKeyPath, "utf-8")
|
||||
const caCertPem = fs.readFileSync(caCertPath, "utf-8")
|
||||
|
||||
const { keyPem, certPem } = generateServerCertificate({
|
||||
host: args.host,
|
||||
tlsSANs: args.tlsSANs,
|
||||
caKeyPem,
|
||||
caCertPem,
|
||||
})
|
||||
|
||||
writePemFile(keyPath, keyPem, 0o600)
|
||||
writePemFile(certPath, certPem, 0o644)
|
||||
args.logger.info({ certPath }, "Generated CodeNomad HTTPS certificate")
|
||||
}
|
||||
|
||||
const key = fs.readFileSync(keyPath, "utf-8")
|
||||
const cert = fs.readFileSync(certPath, "utf-8")
|
||||
const ca = fs.readFileSync(caCertPath, "utf-8")
|
||||
|
||||
// Present the CA as part of the chain.
|
||||
const chainedCert = `${cert.trim()}\n${ca.trim()}\n`
|
||||
|
||||
return {
|
||||
httpsOptions: {
|
||||
key,
|
||||
cert: chainedCert,
|
||||
},
|
||||
caCertPath,
|
||||
mode: "generated",
|
||||
}
|
||||
}
|
||||
|
||||
function writePemFile(filePath: string, content: string, mode: number) {
|
||||
fs.writeFileSync(filePath, content, { encoding: "utf-8", mode })
|
||||
try {
|
||||
fs.chmodSync(filePath, mode)
|
||||
} catch {
|
||||
// best effort on platforms that ignore chmod
|
||||
}
|
||||
}
|
||||
|
||||
function generateCaCertificate(): { caKeyPem: string; caCertPem: string } {
|
||||
const forge = loadForge()
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048)
|
||||
const cert = forge.pki.createCertificate()
|
||||
cert.publicKey = keys.publicKey
|
||||
cert.serialNumber = crypto.randomBytes(16).toString("hex")
|
||||
|
||||
const now = new Date()
|
||||
const notBefore = new Date(now.getTime() - 60_000)
|
||||
const notAfter = new Date(now.getTime() + CA_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
|
||||
cert.validity.notBefore = notBefore
|
||||
cert.validity.notAfter = notAfter
|
||||
|
||||
const attrs = [{ name: "commonName", value: "CodeNomad Local CA" }]
|
||||
cert.setSubject(attrs)
|
||||
cert.setIssuer(attrs)
|
||||
|
||||
cert.setExtensions([
|
||||
{ name: "basicConstraints", cA: true },
|
||||
{ name: "keyUsage", keyCertSign: true, cRLSign: true, digitalSignature: true },
|
||||
{ name: "subjectKeyIdentifier" },
|
||||
])
|
||||
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create())
|
||||
|
||||
return {
|
||||
caKeyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
||||
caCertPem: forge.pki.certificateToPem(cert),
|
||||
}
|
||||
}
|
||||
|
||||
function generateServerCertificate(args: {
|
||||
host: string
|
||||
tlsSANs?: string
|
||||
caKeyPem: string
|
||||
caCertPem: string
|
||||
}): { keyPem: string; certPem: string } {
|
||||
const forge = loadForge()
|
||||
|
||||
const caKey = forge.pki.privateKeyFromPem(args.caKeyPem)
|
||||
const caCert = forge.pki.certificateFromPem(args.caCertPem)
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048)
|
||||
const cert = forge.pki.createCertificate()
|
||||
cert.publicKey = keys.publicKey
|
||||
cert.serialNumber = crypto.randomBytes(16).toString("hex")
|
||||
|
||||
const now = new Date()
|
||||
const notBefore = new Date(now.getTime() - 60_000)
|
||||
const notAfter = new Date(now.getTime() + LEAF_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
|
||||
cert.validity.notBefore = notBefore
|
||||
cert.validity.notAfter = notAfter
|
||||
|
||||
const commonName = pickCommonName(args.host)
|
||||
cert.setSubject([{ name: "commonName", value: commonName }])
|
||||
cert.setIssuer(caCert.subject.attributes)
|
||||
|
||||
const san = buildSubjectAltNames(args.host, args.tlsSANs)
|
||||
|
||||
cert.setExtensions([
|
||||
{ name: "basicConstraints", cA: false },
|
||||
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
|
||||
{ name: "extKeyUsage", serverAuth: true },
|
||||
{ name: "subjectAltName", altNames: san },
|
||||
{ name: "subjectKeyIdentifier" },
|
||||
])
|
||||
|
||||
cert.sign(caKey, forge.md.sha256.create())
|
||||
|
||||
return {
|
||||
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
||||
certPem: forge.pki.certificateToPem(cert),
|
||||
}
|
||||
}
|
||||
|
||||
function pickCommonName(host: string): string {
|
||||
if (!host || host === "0.0.0.0") {
|
||||
return "localhost"
|
||||
}
|
||||
if (host === "127.0.0.1") {
|
||||
return "localhost"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
function buildSubjectAltNames(host: string, tlsSANs?: string): Array<{ type: number; value?: string; ip?: string }> {
|
||||
const dns = new Set<string>()
|
||||
const ips = new Set<string>()
|
||||
|
||||
dns.add("localhost")
|
||||
ips.add("127.0.0.1")
|
||||
|
||||
if (host && host !== "0.0.0.0") {
|
||||
if (isIPv4(host)) {
|
||||
ips.add(host)
|
||||
} else {
|
||||
dns.add(host)
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of splitList(tlsSANs)) {
|
||||
if (isIPv4(token)) {
|
||||
ips.add(token)
|
||||
} else if (token) {
|
||||
dns.add(token)
|
||||
}
|
||||
}
|
||||
|
||||
const altNames: Array<{ type: number; value?: string; ip?: string }> = []
|
||||
|
||||
// 2 = DNS, 7 = IP
|
||||
for (const name of Array.from(dns)) {
|
||||
altNames.push({ type: 2, value: name })
|
||||
}
|
||||
for (const ip of Array.from(ips)) {
|
||||
altNames.push({ type: 7, ip })
|
||||
}
|
||||
|
||||
return altNames
|
||||
}
|
||||
|
||||
function splitList(input: string | undefined): string[] {
|
||||
if (!input) return []
|
||||
return input
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isIPv4(value: string): boolean {
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
241
packages/server/src/workspaces/git-worktrees.ts
Normal file
241
packages/server/src/workspaces/git-worktrees.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import path from "path"
|
||||
import { spawn } from "child_process"
|
||||
import type { WorktreeDescriptor } from "../api-types"
|
||||
import { promises as fsp } from "fs"
|
||||
|
||||
export interface LogLike {
|
||||
debug?: (obj: any, msg?: string) => void
|
||||
warn?: (obj: any, msg?: string) => void
|
||||
}
|
||||
|
||||
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||
|
||||
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
child.once("error", (error) => {
|
||||
resolve({ ok: false, error, stdout, stderr })
|
||||
})
|
||||
child.once("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ ok: true, stdout })
|
||||
} else {
|
||||
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
|
||||
resolve({ ok: false, error, stdout, stderr })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
||||
if (!result.ok) {
|
||||
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||
return { repoRoot: folder, isGitRepo: false }
|
||||
}
|
||||
const repoRoot = result.stdout.trim()
|
||||
if (!repoRoot) {
|
||||
return { repoRoot: folder, isGitRepo: false }
|
||||
}
|
||||
return { repoRoot, isGitRepo: true }
|
||||
}
|
||||
|
||||
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
|
||||
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||
const lines = output.split(/\r?\n/)
|
||||
let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {}
|
||||
|
||||
const flush = () => {
|
||||
if (current.worktree) {
|
||||
records.push({ worktree: current.worktree, branch: current.branch })
|
||||
}
|
||||
current = {}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
const [key, ...rest] = trimmed.split(" ")
|
||||
const value = rest.join(" ").trim()
|
||||
if (key === "worktree") {
|
||||
current.worktree = value
|
||||
} else if (key === "branch") {
|
||||
// branch is like refs/heads/foo
|
||||
current.branch = value.replace(/^refs\/heads\//, "")
|
||||
} else if (key === "HEAD") {
|
||||
current.head = value
|
||||
} else if (key === "detached") {
|
||||
current.detached = true
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return records
|
||||
}
|
||||
|
||||
export async function listWorktrees(params: {
|
||||
repoRoot: string
|
||||
workspaceFolder: string
|
||||
logger?: LogLike
|
||||
}): Promise<WorktreeDescriptor[]> {
|
||||
const { repoRoot, workspaceFolder, logger } = params
|
||||
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
||||
|
||||
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||
if (!result.ok) {
|
||||
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||
return [rootDescriptor]
|
||||
}
|
||||
|
||||
const records = parseWorktreePorcelain(result.stdout)
|
||||
|
||||
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||
const seen = new Set<string>(["root"])
|
||||
|
||||
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
|
||||
const branch = (record.branch ?? "").trim()
|
||||
if (branch) {
|
||||
return branch
|
||||
}
|
||||
const head = (record.head ?? "").trim()
|
||||
if (head && /^[0-9a-f]{7,40}$/i.test(head)) {
|
||||
return `detached-${head.slice(0, 7)}`
|
||||
}
|
||||
// Fallback: stable-ish identifier derived from directory basename.
|
||||
const base = path.basename(record.worktree || "")
|
||||
return base ? `worktree-${base}` : "worktree"
|
||||
}
|
||||
|
||||
|
||||
for (const record of records) {
|
||||
const abs = record.worktree
|
||||
if (!abs || typeof abs !== "string") continue
|
||||
|
||||
// Skip the root record (we always expose it as slug="root").
|
||||
if (path.resolve(abs) === path.resolve(repoRoot)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const slug = normalizeSlug(record)
|
||||
if (!slug || slug === "root") {
|
||||
continue
|
||||
}
|
||||
if (seen.has(slug)) {
|
||||
continue
|
||||
}
|
||||
seen.add(slug)
|
||||
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
|
||||
}
|
||||
|
||||
return worktrees
|
||||
}
|
||||
|
||||
export function isValidWorktreeSlug(slug: string): boolean {
|
||||
if (!slug) return false
|
||||
const trimmed = slug.trim()
|
||||
if (!trimmed) return false
|
||||
if (trimmed.length > 200) return false
|
||||
// Disallow control characters; allow branch-like slugs including '/'.
|
||||
if (/[\x00-\x1F\x7F]/.test(trimmed)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export async function createManagedWorktree(params: {
|
||||
repoRoot: string
|
||||
workspaceFolder: string
|
||||
slug: string
|
||||
logger?: LogLike
|
||||
}): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||
const { repoRoot, workspaceFolder, logger } = params
|
||||
const branch = params.slug.trim()
|
||||
|
||||
if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) {
|
||||
throw new Error("Invalid worktree slug")
|
||||
}
|
||||
|
||||
const sanitizeDirName = (input: string): string => {
|
||||
const normalized = input
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return normalized || "worktree"
|
||||
}
|
||||
|
||||
const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees")
|
||||
const targetDir = path.join(worktreesDir, sanitizeDirName(branch))
|
||||
await fsp.mkdir(worktreesDir, { recursive: true })
|
||||
|
||||
try {
|
||||
const stat = await fsp.stat(targetDir)
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error("Worktree directory already exists")
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== "ENOENT") {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree")
|
||||
|
||||
// Prefer creating a new branch from HEAD.
|
||||
const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder)
|
||||
if (first.ok) {
|
||||
return { slug: branch, directory: targetDir, branch }
|
||||
}
|
||||
|
||||
const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase()
|
||||
if (message.includes("already exists")) {
|
||||
// If the branch already exists, add worktree for that branch.
|
||||
const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder)
|
||||
if (second.ok) {
|
||||
return { slug: branch, directory: targetDir, branch }
|
||||
}
|
||||
throw second.error
|
||||
}
|
||||
|
||||
throw first.error
|
||||
}
|
||||
|
||||
export async function removeWorktree(params: {
|
||||
workspaceFolder: string
|
||||
directory: string
|
||||
force?: boolean
|
||||
logger?: LogLike
|
||||
}): Promise<void> {
|
||||
const { workspaceFolder, logger } = params
|
||||
const directory = (params.directory ?? "").trim()
|
||||
if (!directory) {
|
||||
throw new Error("Invalid worktree directory")
|
||||
}
|
||||
logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree")
|
||||
|
||||
const args = ["worktree", "remove"]
|
||||
if (params.force) {
|
||||
args.push("--force")
|
||||
}
|
||||
args.push(directory)
|
||||
|
||||
const result = await runGit(args, workspaceFolder)
|
||||
if (!result.ok) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
// Best-effort cleanup of stale metadata.
|
||||
await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export class InstanceEventBridge {
|
||||
}
|
||||
|
||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||
const url = `http://${INSTANCE_HOST}:${port}/global/event`
|
||||
|
||||
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
||||
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
@@ -165,8 +165,32 @@ export class InstanceEventBridge {
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
||||
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
||||
const parsed = JSON.parse(payload) as any
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
|
||||
return
|
||||
}
|
||||
|
||||
// OpenCode SSE payload shapes vary across versions.
|
||||
// Common variants:
|
||||
// - { type, properties, ... }
|
||||
// - { payload: { type, properties, ... }, directory: "/abs/path" }
|
||||
// - { payload: { type, properties, ... } }
|
||||
const base = parsed.payload && typeof parsed.payload === "object" ? parsed.payload : parsed
|
||||
|
||||
const event: InstanceStreamEvent | null = base && typeof base === "object" ? ({ ...base } as any) : null
|
||||
|
||||
// Attach directory when available (don't overwrite if already present).
|
||||
if (event && !(event as any).directory && typeof (parsed as any).directory === "string") {
|
||||
;(event as any).directory = (parsed as any).directory
|
||||
}
|
||||
|
||||
if (!event || typeof (event as any).type !== "string") {
|
||||
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
|
||||
return
|
||||
}
|
||||
|
||||
this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received")
|
||||
if (this.options.logger.isLevelEnabled("trace")) {
|
||||
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ interface WorkspaceManagerOptions {
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
getServerBaseUrl: () => string
|
||||
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
|
||||
nodeExtraCaCertsPath?: string
|
||||
}
|
||||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
@@ -91,7 +93,7 @@ export class WorkspaceManager {
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
|
||||
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
@@ -132,6 +134,7 @@ export class WorkspaceManager {
|
||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
|
||||
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
129
packages/server/src/workspaces/worktree-map.ts
Normal file
129
packages/server/src/workspaces/worktree-map.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import path from "path"
|
||||
import type { WorktreeMap } from "../api-types"
|
||||
import { resolveRepoRoot } from "./git-worktrees"
|
||||
import type { LogLike } from "./git-worktrees"
|
||||
|
||||
const DEFAULT_MAP: WorktreeMap = {
|
||||
version: 1,
|
||||
defaultWorktreeSlug: "root",
|
||||
parentSessionWorktreeSlug: {},
|
||||
}
|
||||
|
||||
function getMapPath(repoRoot: string): string {
|
||||
return path.join(repoRoot, ".codenomad", "worktreeMap.json")
|
||||
}
|
||||
|
||||
function getGitExcludePath(repoRoot: string): string {
|
||||
return path.join(repoRoot, ".git", "info", "exclude")
|
||||
}
|
||||
|
||||
async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<void> {
|
||||
const excludePath = getGitExcludePath(repoRoot)
|
||||
try {
|
||||
await fsp.mkdir(path.dirname(excludePath), { recursive: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = [
|
||||
".codenomad/worktrees/",
|
||||
".codenomad/worktreeMap.json",
|
||||
]
|
||||
|
||||
let existing = ""
|
||||
try {
|
||||
existing = await fsp.readFile(excludePath, "utf-8")
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== "ENOENT") {
|
||||
logger?.debug?.({ err: error, excludePath }, "Failed to read .git/info/exclude")
|
||||
return
|
||||
}
|
||||
existing = ""
|
||||
}
|
||||
|
||||
const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean))
|
||||
const missing = entries.filter((e) => !lines.has(e))
|
||||
if (missing.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const header = existing.includes("# codenomad") ? "" : (existing.trim() ? "\n" : "") + "# codenomad\n"
|
||||
const suffix = missing.map((e) => `${e}\n`).join("")
|
||||
await fsp.writeFile(excludePath, `${existing}${header}${suffix}`, "utf-8")
|
||||
}
|
||||
|
||||
export async function ensureCodenomadGitExclude(workspaceFolder: string, logger?: LogLike): Promise<void> {
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||
if (!isGitRepo) {
|
||||
return
|
||||
}
|
||||
await ensureGitExclude(repoRoot, logger)
|
||||
}
|
||||
|
||||
export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise<WorktreeMap> {
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||
const filePath = getMapPath(repoRoot)
|
||||
try {
|
||||
const raw = await fsp.readFile(filePath, "utf-8")
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
const version = (parsed as any).version
|
||||
if (version !== 1) {
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root"
|
||||
const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug
|
||||
const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {}
|
||||
return {
|
||||
version: 1,
|
||||
defaultWorktreeSlug,
|
||||
parentSessionWorktreeSlug: { ...mapping },
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === "ENOENT") {
|
||||
if (isGitRepo) {
|
||||
// Best-effort ignore setup on first use.
|
||||
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||
}
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
logger?.warn?.({ err: error, filePath }, "Failed to read worktree map")
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise<void> {
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||
const filePath = getMapPath(repoRoot)
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||
|
||||
// Ensure ignore rules are present (local-only).
|
||||
if (isGitRepo) {
|
||||
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||
}
|
||||
|
||||
const payload: WorktreeMap = {
|
||||
version: 1,
|
||||
defaultWorktreeSlug: next.defaultWorktreeSlug || "root",
|
||||
parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {},
|
||||
}
|
||||
|
||||
// Write atomically.
|
||||
const tmpPath = `${filePath}.${process.pid}.tmp`
|
||||
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8")
|
||||
await fsp.rename(tmpPath, filePath)
|
||||
}
|
||||
|
||||
export function worktreeMapExists(repoRoot: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(getMapPath(repoRoot))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
778
packages/tauri-app/Cargo.lock
generated
778
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.3",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.2", features = [] }
|
||||
@@ -21,3 +22,4 @@ tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
url = "2"
|
||||
tauri-plugin-keepawake = "0.1.1"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2378,6 +2378,36 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2378,6 +2378,36 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
|
||||
@@ -531,7 +531,7 @@ impl CliProcessManager {
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||
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 token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
@@ -559,12 +559,12 @@ impl CliProcessManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(port) = port_regex
|
||||
if let Some(url) = local_url_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
.map(|m| m.as_str().to_string())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -574,13 +574,13 @@ impl CliProcessManager {
|
||||
.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, port);
|
||||
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, port as u16);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -597,12 +597,15 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
port: u16,
|
||||
base_url: String,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let port = Url::parse(&base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.port_or_known_default())
|
||||
.map(|p| p as u16);
|
||||
let mut locked = status.lock();
|
||||
locked.port = Some(port);
|
||||
locked.port = port;
|
||||
locked.url = Some(base_url.clone());
|
||||
locked.state = CliState::Ready;
|
||||
locked.error = None;
|
||||
@@ -611,22 +614,29 @@ impl CliProcessManager {
|
||||
let token = bootstrap_token.lock().take();
|
||||
|
||||
if let Some(token) = token {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
navigate_main(app, &base_url);
|
||||
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
|
||||
// skip the exchange and let the user authenticate normally.
|
||||
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
|
||||
if scheme.as_deref() != Some("http") {
|
||||
navigate_main(app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
navigate_main(app, &base_url);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
log_line("bootstrap token exchange failed (invalid token)");
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
Err(err) => {
|
||||
log_line(&format!("bootstrap token exchange failed: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
log_line("bootstrap token exchange failed (invalid token)");
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
Err(err) => {
|
||||
log_line(&format!("bootstrap token exchange failed: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -709,19 +719,24 @@ impl CliEntry {
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--port".to_string(),
|
||||
"0".to_string(),
|
||||
"--generate-token".to_string(),
|
||||
];
|
||||
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
|
||||
|
||||
if dev {
|
||||
// Dev: plain HTTP + Vite dev server proxy.
|
||||
args.push("--https".to_string());
|
||||
args.push("false".to_string());
|
||||
args.push("--http".to_string());
|
||||
args.push("true".to_string());
|
||||
args.push("--ui-dev-server".to_string());
|
||||
args.push("http://localhost:3000".to_string());
|
||||
args.push("--log-level".to_string());
|
||||
args.push("debug".to_string());
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https".to_string());
|
||||
args.push("true".to_string());
|
||||
args.push("--http".to_string());
|
||||
args.push("true".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
||||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||
}
|
||||
@@ -73,6 +74,7 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_keepawake::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
|
||||
1
packages/ui/.gitignore
vendored
1
packages/ui/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
src/renderer/public/logo.png
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.9.3",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -26,14 +27,17 @@
|
||||
"qrcode": "^1.5.3",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
"solid-toast": "^0.5.0",
|
||||
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"autoprefixer": "10.4.21",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getLogger } from "./lib/logger"
|
||||
import { initReleaseNotifications } from "./stores/releases"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
@@ -48,6 +49,8 @@ import {
|
||||
updateSessionModel,
|
||||
} from "./stores/sessions"
|
||||
|
||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
@@ -60,6 +63,7 @@ const App: Component = () => {
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
@@ -90,6 +94,26 @@ const App: Component = () => {
|
||||
initReleaseNotifications()
|
||||
})
|
||||
|
||||
const shouldHoldWakeLock = createMemo(() => {
|
||||
const map = instances()
|
||||
for (const id of map.keys()) {
|
||||
const status = getInstanceSessionIndicatorStatus(id)
|
||||
if (status !== "idle") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const hold = shouldHoldWakeLock()
|
||||
void setWakeLockDesired(hold)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
void setWakeLockDesired(false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
instances()
|
||||
hasInstances()
|
||||
@@ -271,6 +295,7 @@ const App: Component = () => {
|
||||
toggleShowThinkingBlocks,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
|
||||
@@ -55,7 +55,7 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
|
||||
const highlighted = highlighter.codeToHtml(props.code, {
|
||||
lang: props.language as CodeToHtmlOptions["lang"],
|
||||
theme: isDark() ? "github-dark" : "github-light",
|
||||
theme: isDark() ? "github-dark" : "github-light-high-contrast",
|
||||
})
|
||||
setHtml(highlighted)
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
@@ -313,8 +314,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
</div>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -322,8 +324,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
@@ -52,6 +53,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<ThemeModeToggle class="new-tab-button" />
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
|
||||
@@ -12,16 +12,14 @@ import {
|
||||
} from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import { Accordion } from "@kobalte/core"
|
||||
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
import AppBar from "@suid/material/AppBar"
|
||||
import Box from "@suid/material/Box"
|
||||
import Divider from "@suid/material/Divider"
|
||||
import Drawer from "@suid/material/Drawer"
|
||||
import IconButton from "@suid/material/IconButton"
|
||||
import Toolbar from "@suid/material/Toolbar"
|
||||
import Typography from "@suid/material/Typography"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import CloseIcon from "@suid/icons-material/Close"
|
||||
import MenuIcon from "@suid/icons-material/Menu"
|
||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||
@@ -37,6 +35,7 @@ import {
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessionThreads,
|
||||
loadMessages,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
@@ -65,6 +64,7 @@ import { formatTokenTotal } from "../../lib/formatters"
|
||||
import { sseManager } from "../../lib/sse-manager"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import WorktreeSelector from "../worktree-selector"
|
||||
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
@@ -95,7 +95,6 @@ const RIGHT_DRAWER_WIDTH = 260
|
||||
const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||
const MAX_RIGHT_DRAWER_WIDTH = 380
|
||||
const SESSION_CACHE_LIMIT = 5
|
||||
const APP_BAR_HEIGHT = 56
|
||||
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
||||
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
||||
const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
|
||||
@@ -153,6 +152,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||
|
||||
// Worktree selector manages its own dialogs.
|
||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||
|
||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
||||
|
||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||
@@ -215,10 +217,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const host = drawerHost()
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
const toolbar = host.querySelector<HTMLElement>(".session-toolbar")
|
||||
const toolbarHeight = toolbar?.offsetHeight ?? APP_BAR_HEIGHT
|
||||
setFloatingDrawerTop(rect.top + toolbarHeight)
|
||||
setFloatingDrawerHeight(Math.max(0, rect.height - toolbarHeight))
|
||||
setFloatingDrawerTop(rect.top)
|
||||
setFloatingDrawerHeight(Math.max(0, rect.height))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -618,7 +618,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fallbackDrawerTop = () => APP_BAR_HEIGHT + props.tabBarOffset
|
||||
const fallbackDrawerTop = () => props.tabBarOffset
|
||||
const floatingTop = () => {
|
||||
const measured = floatingDrawerTop()
|
||||
if (measured > 0) return measured
|
||||
@@ -728,27 +728,21 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const leftAppBarButtonLabel = () => {
|
||||
const state = leftDrawerState()
|
||||
if (state === "pinned") return t("instanceShell.leftDrawer.toggle.pinned")
|
||||
if (state === "floating-closed") return t("instanceShell.leftDrawer.toggle.open")
|
||||
return t("instanceShell.leftDrawer.toggle.close")
|
||||
return t("instanceShell.leftDrawer.toggle.open")
|
||||
}
|
||||
|
||||
const rightAppBarButtonLabel = () => {
|
||||
const state = rightDrawerState()
|
||||
if (state === "pinned") return t("instanceShell.rightDrawer.toggle.pinned")
|
||||
if (state === "floating-closed") return t("instanceShell.rightDrawer.toggle.open")
|
||||
return t("instanceShell.rightDrawer.toggle.close")
|
||||
return t("instanceShell.rightDrawer.toggle.open")
|
||||
}
|
||||
|
||||
const leftAppBarButtonIcon = () => {
|
||||
const state = leftDrawerState()
|
||||
if (state === "floating-closed") return <MenuIcon fontSize="small" />
|
||||
return <MenuOpenIcon fontSize="small" />
|
||||
return <MenuIcon fontSize="small" />
|
||||
}
|
||||
|
||||
const rightAppBarButtonIcon = () => {
|
||||
const state = rightDrawerState()
|
||||
if (state === "floating-closed") return <MenuIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
||||
return <MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
||||
return <MenuIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
||||
}
|
||||
|
||||
|
||||
@@ -796,29 +790,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
const handleLeftAppBarButtonClick = () => {
|
||||
const state = leftDrawerState()
|
||||
if (state === "pinned") return
|
||||
if (state === "floating-closed") {
|
||||
setLeftOpen(true)
|
||||
measureDrawerHost()
|
||||
return
|
||||
}
|
||||
blurIfInside(leftDrawerContentEl())
|
||||
setLeftOpen(false)
|
||||
focusTarget(leftToggleButtonEl())
|
||||
if (state !== "floating-closed") return
|
||||
setLeftOpen(true)
|
||||
measureDrawerHost()
|
||||
}
|
||||
|
||||
const handleRightAppBarButtonClick = () => {
|
||||
const state = rightDrawerState()
|
||||
if (state === "pinned") return
|
||||
if (state === "floating-closed") {
|
||||
setRightOpen(true)
|
||||
measureDrawerHost()
|
||||
return
|
||||
}
|
||||
blurIfInside(rightDrawerContentEl())
|
||||
setRightOpen(false)
|
||||
focusTarget(rightToggleButtonEl())
|
||||
if (state !== "floating-closed") return
|
||||
setRightOpen(true)
|
||||
measureDrawerHost()
|
||||
}
|
||||
|
||||
@@ -864,39 +844,66 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
const LeftDrawerContent = () => (
|
||||
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
|
||||
<div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => handleSessionSelect("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!isPhoneLayout()}>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={leftPinned() ? t("instanceShell.leftDrawer.unpin") : t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
|
||||
aria-label={t("sessionList.filter.ariaLabel")}
|
||||
title={t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={showSessionSearch()}
|
||||
onClick={() => setShowSessionSearch((current) => !current)}
|
||||
sx={{
|
||||
color: showSessionSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: showSessionSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<Search class={showSessionSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => handleSessionSelect("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={leftPinned() ? t("instanceShell.leftDrawer.unpin") : t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
|
||||
>
|
||||
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={leftDrawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={closeLeftDrawer}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
@@ -911,22 +918,24 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={showSessionSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
|
||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||
<AgentSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||
<WorktreeSelector instanceId={props.instance.id} sessionId={activeSession().id} />
|
||||
|
||||
<AgentSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instance.id}
|
||||
@@ -1087,22 +1096,46 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-base">
|
||||
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
|
||||
{t("instanceShell.rightPanel.title")}
|
||||
</Typography>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={!isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
|
||||
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
|
||||
>
|
||||
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
<div class="border-b border-base text-primary">
|
||||
<div class="relative flex items-center px-4 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={rightDrawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={t("instanceShell.rightDrawer.toggle.close")}
|
||||
title={t("instanceShell.rightDrawer.toggle.close")}
|
||||
onClick={closeRightDrawer}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={!isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
|
||||
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
|
||||
>
|
||||
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{t("instanceShell.rightPanel.title")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<ContextUsagePanel
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
class="border-t border-base"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<Accordion.Root
|
||||
@@ -1263,19 +1296,93 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
const sessionLayout = (
|
||||
<div
|
||||
class="session-shell-panels flex flex-col flex-1 min-h-0 overflow-x-hidden"
|
||||
class="session-shell-panels flex flex-1 min-h-0 overflow-x-hidden"
|
||||
ref={(element) => {
|
||||
setDrawerHost(element)
|
||||
measureDrawerHost()
|
||||
}}
|
||||
>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!isPhoneLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
{renderLeftPanel()}
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!isPhoneLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<Show when={leftDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleLeftAppBarButtonClick}
|
||||
aria-label={leftAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={leftDrawerState() !== "floating-closed"}
|
||||
>
|
||||
{leftAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1 justify-center">
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
>
|
||||
{t("instanceShell.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
<span
|
||||
class={`status-indicator ${connectionStatusClass()}`}
|
||||
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
|
||||
>
|
||||
<span class="status-dot" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={rightDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleRightAppBarButtonClick}
|
||||
aria-label={rightAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={rightDrawerState() !== "floating-closed"}
|
||||
>
|
||||
{rightAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
|
||||
<Show when={leftDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
color="inherit"
|
||||
@@ -1283,38 +1390,68 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
aria-label={leftAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={leftDrawerState() !== "floating-closed"}
|
||||
disabled={leftDrawerState() === "pinned"}
|
||||
>
|
||||
{leftAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1 justify-center">
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
>
|
||||
{t("instanceShell.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
<Show when={!showingInfoView()}>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span
|
||||
class={`status-indicator ${connectionStatusClass()}`}
|
||||
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
|
||||
>
|
||||
<span class="status-dot" />
|
||||
</span>
|
||||
|
||||
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
>
|
||||
{t("instanceShell.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="session-toolbar-right flex items-center gap-3">
|
||||
<div class="connection-status-meta flex items-center gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">{t("instanceShell.connection.connected")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">{t("instanceShell.connection.connecting")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={rightDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
color="inherit"
|
||||
@@ -1322,120 +1459,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
aria-label={rightAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={rightDrawerState() !== "floating-closed"}
|
||||
disabled={rightDrawerState() === "pinned"}
|
||||
>
|
||||
{rightAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleLeftAppBarButtonClick}
|
||||
aria-label={leftAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={leftDrawerState() !== "floating-closed"}
|
||||
disabled={leftDrawerState() === "pinned"}
|
||||
>
|
||||
{leftAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
|
||||
<Show when={!showingInfoView()}>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
>
|
||||
{t("instanceShell.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="session-toolbar-right flex items-center gap-3">
|
||||
<div class="connection-status-meta flex items-center gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">{t("instanceShell.connection.connected")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">{t("instanceShell.connection.connecting")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleRightAppBarButtonClick}
|
||||
aria-label={rightAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={rightDrawerState() !== "floating-closed"}
|
||||
disabled={rightDrawerState() === "pinned"}
|
||||
>
|
||||
{rightAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</div>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ display: "flex", flex: 1, minHeight: 0, overflowX: "hidden" }}>
|
||||
{renderLeftPanel()}
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
@@ -1489,9 +1520,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
</Box>
|
||||
|
||||
{renderRightPanel()}
|
||||
</Box>
|
||||
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
@@ -72,6 +72,9 @@ export function Markdown(props: MarkdownProps) {
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
|
||||
// Ensure the markdown highlighter theme matches the active UI theme.
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
|
||||
latestRequestedText = text
|
||||
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
@@ -171,6 +174,8 @@ export function Markdown(props: MarkdownProps) {
|
||||
|
||||
const { part, text, themeKey, version } = resolved()
|
||||
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
|
||||
if (latestRequestedText !== text) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { FoldVertical } from "lucide-solid"
|
||||
import { ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
@@ -11,6 +11,8 @@ 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 { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessagePart } from "../stores/session-actions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const TOOL_ICON = "🔧"
|
||||
@@ -302,6 +304,7 @@ interface ToolCallItemProps {
|
||||
|
||||
function ToolCallItem(props: ToolCallItemProps) {
|
||||
const { t } = useI18n()
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
@@ -318,6 +321,14 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||
|
||||
const deleteDisabled = createMemo(() => {
|
||||
if (deleting()) return true
|
||||
// Avoid deleting while a tool is actively running to prevent confusing UI states.
|
||||
if (isToolStateRunning(toolState())) return true
|
||||
// Avoid deleting permission prompts from here; those are interactive.
|
||||
return Boolean(toolPart()?.pendingPermission)
|
||||
})
|
||||
|
||||
const taskSessionId = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return ""
|
||||
@@ -341,6 +352,26 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
navigateToTaskSession(location)
|
||||
}
|
||||
|
||||
const handleDeleteToolPart = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (deleteDisabled()) return
|
||||
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
|
||||
title: t("messageBlock.tool.deletePart.failed.title"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={toolPart()}>
|
||||
{(resolvedToolPart) => (
|
||||
@@ -351,17 +382,32 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
<span>{t("messageBlock.tool.header")}</span>
|
||||
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
|
||||
</div>
|
||||
<Show when={taskSessionId()}>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={taskSessionId()}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation()}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={t("messageBlock.tool.goToSession.label")}
|
||||
aria-label={t("messageBlock.tool.goToSession.label")}
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation()}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={!taskLocation() ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")}
|
||||
disabled={deleteDisabled()}
|
||||
onClick={handleDeleteToolPart}
|
||||
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
||||
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
||||
>
|
||||
{t("messageBlock.tool.goToSession.label")}
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolCall
|
||||
@@ -395,6 +441,8 @@ type ReasoningDisplayItem = {
|
||||
messageInfo?: MessageInfo
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded: boolean
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
type CompactionDisplayItem = {
|
||||
@@ -403,6 +451,8 @@ type CompactionDisplayItem = {
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
accentColor?: string
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
|
||||
@@ -530,7 +580,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
|
||||
if (part.type === "compaction") {
|
||||
flushContent()
|
||||
const key = `${current.id}:${part.id ?? partIndex}:compaction`
|
||||
const partId = part.id ?? ""
|
||||
const key = `${current.id}:${partId || partIndex}:compaction`
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
items.push({
|
||||
type: "compaction",
|
||||
@@ -538,6 +589,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
part,
|
||||
messageInfo: info,
|
||||
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
|
||||
messageId: current.id,
|
||||
partId,
|
||||
})
|
||||
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
|
||||
return
|
||||
@@ -562,7 +615,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
if (part.type === "reasoning") {
|
||||
flushContent()
|
||||
if (props.showThinking() && reasoningHasRenderableContent(part)) {
|
||||
const key = `${current.id}:${part.id ?? partIndex}:reasoning`
|
||||
const partId = part.id ?? ""
|
||||
const key = `${current.id}:${partId || partIndex}:reasoning`
|
||||
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
|
||||
if (showAgentMeta) {
|
||||
agentMetaAttached = true
|
||||
@@ -574,6 +628,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
messageInfo: info,
|
||||
showAgentMeta,
|
||||
defaultExpanded: props.thinkingDefaultExpanded(),
|
||||
messageId: current.id,
|
||||
partId,
|
||||
})
|
||||
lastAccentColor = ASSISTANT_BORDER_COLOR
|
||||
}
|
||||
@@ -647,7 +703,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={item.type === "step-start"}>
|
||||
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
|
||||
<StepCard
|
||||
kind="start"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
showAgentMeta
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
<StepCard
|
||||
@@ -659,7 +720,15 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "compaction"}>
|
||||
<CompactionCard part={(item as CompactionDisplayItem).part} messageInfo={(item as CompactionDisplayItem).messageInfo} borderColor={(item as CompactionDisplayItem).accentColor} />
|
||||
<CompactionCard
|
||||
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}
|
||||
partId={(item as CompactionDisplayItem).partId}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
<ReasoningCard
|
||||
@@ -667,6 +736,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as ReasoningDisplayItem).messageId}
|
||||
partId={(item as ReasoningDisplayItem).partId}
|
||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||
/>
|
||||
@@ -689,8 +760,19 @@ interface StepCardProps {
|
||||
borderColor?: string
|
||||
}
|
||||
|
||||
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
|
||||
interface CompactionCardProps {
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
borderColor?: string
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
function CompactionCard(props: CompactionCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
const isAuto = () => Boolean((props.part as any)?.auto)
|
||||
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
|
||||
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
||||
@@ -698,13 +780,43 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
|
||||
const containerClass = () =>
|
||||
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
|
||||
|
||||
const canDelete = () => Boolean(props.partId) && !deleting()
|
||||
|
||||
const handleDelete = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={containerClass()}
|
||||
class={`${containerClass()} relative`}
|
||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||
role="status"
|
||||
aria-label={t("messageBlock.compaction.ariaLabel")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
|
||||
disabled={!canDelete()}
|
||||
onClick={handleDelete}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
</button>
|
||||
|
||||
<div class="message-compaction-row">
|
||||
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
||||
<span class="message-compaction-label">{label()}</span>
|
||||
@@ -759,6 +871,7 @@ function StepCard(props: StepCardProps) {
|
||||
|
||||
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
|
||||
|
||||
|
||||
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||
const entries = [
|
||||
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
|
||||
@@ -824,6 +937,8 @@ interface ReasoningCardProps {
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
@@ -831,6 +946,7 @@ interface ReasoningCardProps {
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
@@ -894,6 +1010,27 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
const toggle = () => setExpanded((prev) => !prev)
|
||||
|
||||
const hasDeleteTarget = () => Boolean(props.partId)
|
||||
const canDelete = () => hasDeleteTarget() && !deleting()
|
||||
|
||||
const handleDelete = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="message-reasoning-card">
|
||||
<button
|
||||
@@ -924,6 +1061,25 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
<span class="message-reasoning-indicator">
|
||||
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
|
||||
</span>
|
||||
|
||||
<Show when={hasDeleteTarget()}>
|
||||
<span
|
||||
class={`message-reasoning-indicator${canDelete() ? "" : " opacity-50 pointer-events-none"}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleDelete}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
handleDelete(event)
|
||||
}
|
||||
}}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<span class="message-reasoning-time">{timestamp()}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { Copy, Split, Trash2, Undo } from "lucide-solid"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessagePart } from "../stores/session-actions"
|
||||
|
||||
interface MessageItemProps {
|
||||
record: MessageRecord
|
||||
@@ -22,6 +25,7 @@ interface MessageItemProps {
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const { t } = useI18n()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const isUser = () => props.record.role === "user"
|
||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||
@@ -156,6 +160,8 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const copyLabel = () => (copied() ? t("messageItem.actions.copied") : t("messageItem.actions.copy"))
|
||||
|
||||
const getRawContent = () => {
|
||||
return props.parts
|
||||
.filter(part => part.type === "text")
|
||||
@@ -172,6 +178,50 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const deletableTextPartId = () => {
|
||||
const part = props.parts.find((candidate) => {
|
||||
if (!candidate || candidate.type !== "text") return false
|
||||
const id = (candidate as any).id
|
||||
if (typeof id !== "string" || id.length === 0) return false
|
||||
return !Boolean((candidate as any).synthetic)
|
||||
})
|
||||
return (part as any)?.id as string | undefined
|
||||
}
|
||||
|
||||
const isDeletingPart = (partId?: string) => {
|
||||
if (!partId) return false
|
||||
return deletingParts().has(partId)
|
||||
}
|
||||
|
||||
const setPartDeleting = (partId: string, value: boolean) => {
|
||||
setDeletingParts((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (value) {
|
||||
next.add(partId)
|
||||
} else {
|
||||
next.delete(partId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeletePart = async (partId?: string) => {
|
||||
if (!partId) return
|
||||
if (isDeletingPart(partId)) return
|
||||
setPartDeleting(partId, true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setPartDeleting(partId, false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent() && !isGenerating()) {
|
||||
return null
|
||||
}
|
||||
@@ -231,45 +281,70 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title={t("messageItem.actions.revertTitle")}
|
||||
aria-label={t("messageItem.actions.revertTitle")}
|
||||
title={t("messageItem.actions.revert")}
|
||||
aria-label={t("messageItem.actions.revert")}
|
||||
>
|
||||
{t("messageItem.actions.revert")}
|
||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => props.onFork?.(props.record.id)}
|
||||
title={t("messageItem.actions.forkTitle")}
|
||||
aria-label={t("messageItem.actions.forkTitle")}
|
||||
title={t("messageItem.actions.fork")}
|
||||
aria-label={t("messageItem.actions.fork")}
|
||||
>
|
||||
{t("messageItem.actions.fork")}
|
||||
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={t("messageItem.actions.copyTitle")}
|
||||
aria-label={t("messageItem.actions.copyTitle")}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
||||
{t("messageItem.actions.copied")}
|
||||
</Show>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={t("messageItem.actions.copyTitle")}
|
||||
aria-label={t("messageItem.actions.copyTitle")}
|
||||
>
|
||||
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
||||
{t("messageItem.actions.copied")}
|
||||
<div class="message-action-group">
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||
</div>
|
||||
@@ -337,6 +412,19 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeletePart(attachment.id)}
|
||||
class="attachment-remove"
|
||||
disabled={isDeletingPart(attachment.id)}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={name} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import Kbd from "./kbd"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
||||
|
||||
interface MessageListHeaderProps {
|
||||
usedTokens: number
|
||||
|
||||
@@ -15,7 +15,7 @@ interface MessagePartProps {
|
||||
sessionId: string
|
||||
onRendered?: () => void
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { preferences } = useConfig()
|
||||
@@ -32,6 +32,7 @@ interface MessagePartProps {
|
||||
return Boolean((part as any).synthetic) && props.messageType !== "user"
|
||||
}
|
||||
|
||||
|
||||
const plainTextContent = () => {
|
||||
const part = props.part
|
||||
|
||||
@@ -103,21 +104,21 @@ interface MessagePartProps {
|
||||
<Match when={partType() === "text"}>
|
||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||
<div class={textContainerClass()}>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span>{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span class="text-primary">{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getSessionInfo } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
@@ -375,7 +377,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
|
||||
const shellRect = shell.getBoundingClientRect()
|
||||
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
|
||||
const maxLeft = Math.max(shell.clientWidth - 180, 8)
|
||||
// Keep the popover within the stream shell. The quote popover currently
|
||||
// renders 3 actions; keep enough horizontal room for the pill.
|
||||
const maxLeft = Math.max(shell.clientWidth - 260, 8)
|
||||
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
|
||||
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
|
||||
}
|
||||
@@ -394,6 +398,24 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopySelectionRequest() {
|
||||
const info = quoteSelection()
|
||||
if (!info) return
|
||||
|
||||
const success = await copyToClipboard(info.text)
|
||||
showToastNotification({
|
||||
message: success ? t("messageSection.quote.copied") : t("messageSection.quote.copyFailed"),
|
||||
variant: success ? "success" : "error",
|
||||
duration: success ? 2000 : 6000,
|
||||
})
|
||||
|
||||
clearQuoteSelection()
|
||||
if (typeof window !== "undefined") {
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
if (props.loading) {
|
||||
@@ -835,6 +857,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||
{t("messageSection.quote.addAsCode")}
|
||||
</button>
|
||||
<button type="button" class="message-quote-button" onClick={() => void handleCopySelectionRequest()}>
|
||||
{t("messageSection.quote.copy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getCommands } from "../stores/commands"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { preferences } from "../stores/preferences"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
@@ -542,13 +543,46 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
if (showPicker()) {
|
||||
handlePickerClose()
|
||||
if (e.key === "Enter") {
|
||||
const isModified = e.metaKey || e.ctrlKey
|
||||
|
||||
// If the picker is open, Enter should select from it.
|
||||
if (!isModified && showPicker()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (submitOnEnter()) {
|
||||
// Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline.
|
||||
if (isModified) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
insertNewlineAtCursor()
|
||||
return
|
||||
}
|
||||
|
||||
// Shift+Enter always inserts a newline.
|
||||
if (e.shiftKey) {
|
||||
// If the picker is open, avoid selecting an item on Enter.
|
||||
if (showPicker()) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
return
|
||||
}
|
||||
|
||||
// Default: Cmd/Ctrl+Enter submits.
|
||||
if (isModified) {
|
||||
e.preventDefault()
|
||||
if (showPicker()) {
|
||||
handlePickerClose()
|
||||
}
|
||||
handleSend()
|
||||
return
|
||||
}
|
||||
handleSend()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
@@ -1021,7 +1055,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
||||
if (!blockquote) return
|
||||
|
||||
insertBlockContent(`${blockquote}\n\n`)
|
||||
insertBlockContent(`${blockquote}\n`)
|
||||
}
|
||||
|
||||
function insertCodeSelection(rawText: string) {
|
||||
@@ -1056,6 +1090,25 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
: { key: "!", text: t("promptInput.hints.shell.enable") }
|
||||
const commandHint = () => ({ key: "/", text: t("promptInput.hints.commands") })
|
||||
|
||||
const submitOnEnter = () => preferences().promptSubmitOnEnter
|
||||
|
||||
function insertNewlineAtCursor() {
|
||||
const textarea = textareaRef
|
||||
const current = prompt()
|
||||
const start = textarea ? textarea.selectionStart : current.length
|
||||
const end = textarea ? textarea.selectionEnd : current.length
|
||||
const nextValue = current.substring(0, start) + "\n" + current.substring(end)
|
||||
const nextCursor = start + 1
|
||||
|
||||
setPrompt(nextValue)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!textareaRef) return
|
||||
textareaRef.focus()
|
||||
textareaRef.setSelectionRange(nextCursor, nextCursor)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const shouldShowOverlay = () => prompt().length === 0
|
||||
|
||||
const instance = () => getActiveInstance()
|
||||
@@ -1142,7 +1195,19 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
fallback={
|
||||
<>
|
||||
<span class="prompt-overlay-text">
|
||||
<Kbd>Enter</Kbd> {t("promptInput.overlay.newLine")} • <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.send")} • <Kbd>@</Kbd> {t("promptInput.overlay.filesAgents")} • <Kbd>↑↓</Kbd> {t("promptInput.overlay.history")}
|
||||
<Show
|
||||
when={submitOnEnter()}
|
||||
fallback={
|
||||
<>
|
||||
<Kbd>Enter</Kbd> {t("promptInput.overlay.newLine")} • <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.send")}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Kbd>Enter</Kbd> {t("promptInput.overlay.send")} • <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.newLine")}
|
||||
</>
|
||||
</Show>
|
||||
{" "}• <Kbd>@</Kbd> {t("promptInput.overlay.filesAgents")} • <Kbd>↑↓</Kbd> {t("promptInput.overlay.history")}
|
||||
</span>
|
||||
<Show when={attachments().length > 0}>
|
||||
<span class="prompt-overlay-text prompt-overlay-muted">{t("promptInput.overlay.attachments", { count: attachments().length })}</span>
|
||||
|
||||
@@ -37,10 +37,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const list = addresses()
|
||||
if (allowExternalConnections()) {
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
if (!allowExternalConnections()) {
|
||||
return []
|
||||
}
|
||||
return list.filter((address) => address.scope === "loopback")
|
||||
// Local URL is displayed separately; list only remote-friendly addresses.
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
@@ -311,34 +312,27 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
<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>}>
|
||||
<div class="remote-address-list">
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
const expandedState = () => expandedUrl() === address.url
|
||||
const qr = () => qrCodes()[address.url]
|
||||
const scopeLabel = () =>
|
||||
address.scope === "external"
|
||||
? t("remoteAccess.address.scope.network")
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
<Show when={meta()?.localUrl}>
|
||||
{(url) => {
|
||||
const value = () => url()
|
||||
const expandedState = () => expandedUrl() === value()
|
||||
const qr = () => qrCodes()[value()]
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{address.url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
</p>
|
||||
<p class="remote-address-url">{value()}</p>
|
||||
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(address.url)}
|
||||
onClick={() => void toggleExpanded(value())}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
@@ -352,7 +346,60 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
{(dataUrl) => (
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url: address.url })}
|
||||
alt={t("remoteAccess.address.qrAlt", { url: value() })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<For each={displayAddresses()}>
|
||||
{(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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl
|
||||
import type { SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import {
|
||||
deleteSession,
|
||||
ensureSessionParentExpanded,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
setActiveSessionFromList,
|
||||
toggleSessionParentExpanded,
|
||||
} from "../stores/sessions"
|
||||
import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
const log = getLogger("session")
|
||||
@@ -35,6 +37,7 @@ interface SessionListProps {
|
||||
showFooter?: boolean
|
||||
headerContent?: JSX.Element
|
||||
footerContent?: JSX.Element
|
||||
enableFilterBar?: boolean
|
||||
}
|
||||
|
||||
function formatSessionStatus(status: SessionStatus): string {
|
||||
@@ -46,6 +49,70 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
|
||||
const [filterQuery, setFilterQuery] = createSignal("")
|
||||
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
|
||||
|
||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const normalizeSessionLabel = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const title = (session?.title ?? "").trim()
|
||||
return title || t("sessionList.session.untitled")
|
||||
}
|
||||
|
||||
const sessionMatchesQuery = (sessionId: string, query: string) => {
|
||||
if (!query) return true
|
||||
const label = normalizeSessionLabel(sessionId).toLowerCase()
|
||||
if (label.includes(query)) return true
|
||||
return sessionId.toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
const filteredThreads = createMemo<SessionThread[]>(() => {
|
||||
const query = normalizedQuery()
|
||||
if (!query) return props.threads
|
||||
|
||||
const next: SessionThread[] = []
|
||||
for (const thread of props.threads) {
|
||||
const parentMatches = sessionMatchesQuery(thread.parent.id, query)
|
||||
const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query))
|
||||
|
||||
if (!parentMatches && matchingChildren.length === 0) continue
|
||||
|
||||
next.push({
|
||||
parent: thread.parent,
|
||||
children: matchingChildren,
|
||||
latestUpdated: thread.latestUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
const allMatchingSessionIds = createMemo<string[]>(() => {
|
||||
const ids: string[] = []
|
||||
for (const thread of filteredThreads()) {
|
||||
ids.push(thread.parent.id)
|
||||
for (const child of thread.children) ids.push(child.id)
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const selectedCount = createMemo(() => selectedSessionIds().size)
|
||||
|
||||
const isAllSelected = createMemo(() => {
|
||||
const ids = allMatchingSessionIds()
|
||||
if (ids.length === 0) return false
|
||||
const selected = selectedSessionIds()
|
||||
return ids.every((id) => selected.has(id))
|
||||
})
|
||||
const isSelectAllIndeterminate = createMemo(() => {
|
||||
const ids = allMatchingSessionIds()
|
||||
const total = ids.length
|
||||
if (total === 0) return false
|
||||
const count = selectedCount()
|
||||
return count > 0 && count < total
|
||||
})
|
||||
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
const deleting = loading().deletingSession.get(props.instanceId)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
@@ -54,9 +121,10 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const parentId = session?.parentId ?? session?.id
|
||||
if (parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
// If the user selects a child session, make sure its parent thread is expanded.
|
||||
// For parent sessions we don't force expansion; user can collapse/expand freely.
|
||||
if (session?.parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, session.parentId)
|
||||
}
|
||||
|
||||
props.onSelect(sessionId)
|
||||
@@ -82,6 +150,17 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
event.stopPropagation()
|
||||
if (isSessionDeleting(sessionId)) return
|
||||
|
||||
const confirmed = await showConfirmDialog(
|
||||
t("sessionList.delete.confirmMessage", { label: normalizeSessionLabel(sessionId) }),
|
||||
{
|
||||
title: t("sessionList.delete.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.delete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.delete.cancelLabel"),
|
||||
},
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
const shouldSelectFallback = props.activeSessionId === sessionId
|
||||
let fallbackSessionId: string | undefined
|
||||
|
||||
@@ -152,6 +231,115 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedMany = (sessionIds: string[], checked: boolean) => {
|
||||
if (sessionIds.length === 0) return
|
||||
setSelectedSessionIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
sessionIds.forEach((id) => {
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getSelectableThreadIds = (parentId: string): string[] => {
|
||||
const query = normalizedQuery()
|
||||
const source = query ? filteredThreads() : props.threads
|
||||
const thread = source.find((t) => t.parent.id === parentId)
|
||||
if (!thread) return [parentId]
|
||||
return [thread.parent.id, ...thread.children.map((c) => c.id)]
|
||||
}
|
||||
|
||||
const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => {
|
||||
const ids: string[] = []
|
||||
threads.forEach((thread) => {
|
||||
ids.push(thread.parent.id)
|
||||
thread.children.forEach((child) => ids.push(child.id))
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
const handleToggleSelectAll = (checked: boolean) => {
|
||||
const ids = allMatchingSessionIds()
|
||||
setSelectedMany(ids, checked)
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected()) {
|
||||
handleToggleSelectAll(false)
|
||||
return
|
||||
}
|
||||
handleToggleSelectAll(true)
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const selected = Array.from(selectedSessionIds())
|
||||
if (selected.length === 0) return
|
||||
|
||||
const confirmed = await showConfirmDialog(
|
||||
t("sessionList.bulkDelete.confirmMessage", { count: selected.length }),
|
||||
{
|
||||
title: t("sessionList.bulkDelete.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
||||
},
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
const deletedSet = new Set(selected)
|
||||
const currentActiveId = props.activeSessionId
|
||||
|
||||
let fallbackSessionId: string | undefined
|
||||
if (currentActiveId && deletedSet.has(currentActiveId)) {
|
||||
const ordered = getAllSessionIdsInOrder(props.threads)
|
||||
const currentIndex = ordered.indexOf(currentActiveId)
|
||||
|
||||
for (let i = Math.max(0, currentIndex); i < ordered.length; i++) {
|
||||
const candidate = ordered[i]
|
||||
if (candidate && !deletedSet.has(candidate)) {
|
||||
fallbackSessionId = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!fallbackSessionId) {
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const candidate = ordered[i]
|
||||
if (candidate && !deletedSet.has(candidate)) {
|
||||
fallbackSessionId = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let failed = 0
|
||||
for (const sessionId of selected) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteSession(props.instanceId, sessionId)
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
log.error(`Failed to delete session ${sessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedSessionIds(new Set<string>())
|
||||
|
||||
if (fallbackSessionId) {
|
||||
setActiveSessionFromList(props.instanceId, fallbackSessionId)
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
showToastNotification({
|
||||
message: t("sessionList.bulkDelete.error", { count: failed }),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const SessionRow: Component<{
|
||||
@@ -166,6 +354,19 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
if (!session()) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const worktreeSlug = createMemo(() => {
|
||||
if (rowProps.isChild) return "root"
|
||||
return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId)
|
||||
})
|
||||
|
||||
const showWorktreeBadge = createMemo(() => {
|
||||
if (rowProps.isChild) return false
|
||||
if (getGitRepoStatus(props.instanceId) === false) return false
|
||||
const slug = worktreeSlug()
|
||||
return Boolean(slug) && slug !== "root"
|
||||
})
|
||||
|
||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||
const title = () => session()?.title || t("sessionList.session.untitled")
|
||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||
@@ -190,9 +391,31 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
? t("sessionList.status.needsInput")
|
||||
: statusLabel()
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
|
||||
|
||||
const parentGroupState = createMemo(() => {
|
||||
if (rowProps.isChild) {
|
||||
return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] }
|
||||
}
|
||||
|
||||
const ids = getSelectableThreadIds(rowProps.sessionId)
|
||||
const selected = selectedSessionIds()
|
||||
const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0)
|
||||
return {
|
||||
checked: selectedInGroup > 0 && selectedInGroup === ids.length,
|
||||
indeterminate: selectedInGroup > 0 && selectedInGroup < ids.length,
|
||||
ids,
|
||||
}
|
||||
})
|
||||
|
||||
let rowCheckboxEl: HTMLInputElement | null = null
|
||||
createEffect(() => {
|
||||
if (!rowCheckboxEl) return
|
||||
rowCheckboxEl.indeterminate = parentGroupState().indeterminate
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
<button
|
||||
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
data-session-id={rowProps.sessionId}
|
||||
@@ -204,11 +427,23 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
>
|
||||
<div class="session-item-row session-item-header">
|
||||
<div class="session-item-title-row">
|
||||
{rowProps.isChild ? (
|
||||
<Bot class="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<User class="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<Show when={props.enableFilterBar}>
|
||||
<input
|
||||
ref={(el) => {
|
||||
rowCheckboxEl = el
|
||||
}}
|
||||
type="checkbox"
|
||||
checked={parentGroupState().checked}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
setSelectedMany(parentGroupState().ids, event.currentTarget.checked)
|
||||
}}
|
||||
aria-label={t("sessionList.selection.checkboxAriaLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
|
||||
<span class="session-item-title session-item-title--clamp">{title()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,9 +451,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={rowProps.hasChildren && !rowProps.isChild}
|
||||
fallback={
|
||||
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
|
||||
}
|
||||
fallback={rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
|
||||
>
|
||||
<span
|
||||
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
@@ -228,20 +461,24 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")}
|
||||
aria-label={
|
||||
rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")
|
||||
}
|
||||
title={rowProps.expanded ? t("sessionList.expand.collapseTitle") : t("sessionList.expand.expandTitle")}
|
||||
>
|
||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||
</span>
|
||||
</Show>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
{needsInput() ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{statusText()}
|
||||
</span>
|
||||
<Show when={showWorktreeBadge()}>
|
||||
<span class="status-indicator session-status-list worktree-indicator" title={`Worktree: ${worktreeSlug()}`}>
|
||||
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span class="worktree-indicator-label">{worktreeSlug()}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="session-item-actions">
|
||||
<span
|
||||
@@ -309,6 +546,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
// Keep the active child session visible by ensuring its parent is expanded.
|
||||
// Don't force-expanding when the active session itself is a parent lets users collapse it.
|
||||
const activeId = props.activeSessionId
|
||||
if (!activeId || activeId === "info") return
|
||||
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
|
||||
if (!activeSession) return
|
||||
if (!activeSession.parentId) return
|
||||
const parentId = activeParentId()
|
||||
if (!parentId) return
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
@@ -365,6 +609,63 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div
|
||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
||||
>
|
||||
<Show when={props.enableFilterBar}>
|
||||
<div class="p-3 border-b border-base">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true">
|
||||
<Search class="w-4 h-4" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input pl-9"
|
||||
value={filterQuery()}
|
||||
onInput={(e) => setFilterQuery(e.currentTarget.value)}
|
||||
placeholder={t("sessionList.filter.placeholder")}
|
||||
aria-label={t("sessionList.filter.ariaLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary p-2 inline-flex items-center justify-center"
|
||||
onClick={toggleSelectAll}
|
||||
disabled={allMatchingSessionIds().length === 0}
|
||||
aria-label={t("sessionList.selection.selectAllAriaLabel")}
|
||||
title={t("sessionList.selection.selectAllLabel")}
|
||||
>
|
||||
<Show
|
||||
when={isSelectAllIndeterminate()}
|
||||
fallback={isAllSelected() ? <CheckSquare class="w-4 h-4" /> : <Square class="w-4 h-4" />}
|
||||
>
|
||||
<MinusSquare class="w-4 h-4" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={selectedCount() > 0}>
|
||||
<div class="mt-2 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary"
|
||||
onClick={handleBulkDelete}
|
||||
aria-label={t("sessionList.bulkDelete.ariaLabel", { count: selectedCount() })}
|
||||
>
|
||||
{t("sessionList.bulkDelete.button", { count: selectedCount() })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary"
|
||||
onClick={() => setSelectedSessionIds(new Set<string>())}
|
||||
aria-label={t("sessionList.selection.clearAriaLabel")}
|
||||
>
|
||||
{t("sessionList.selection.clearLabel")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showHeader !== false}>
|
||||
<div class="session-list-header p-3 border-b border-base">
|
||||
{props.headerContent ?? (
|
||||
@@ -378,33 +679,33 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
|
||||
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
|
||||
|
||||
<Show when={props.threads.length > 0}>
|
||||
<div class="session-section">
|
||||
<For each={props.threads}>
|
||||
<Show when={filteredThreads().length > 0}>
|
||||
<div class="session-section">
|
||||
<For each={filteredThreads()}>
|
||||
|
||||
{(thread) => {
|
||||
const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id)
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
sessionId={thread.parent.id}
|
||||
hasChildren={thread.children.length > 0}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
|
||||
/>
|
||||
{(thread) => {
|
||||
const expanded = () => (normalizedQuery() ? true : isSessionParentExpanded(props.instanceId, thread.parent.id))
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
sessionId={thread.parent.id}
|
||||
hasChildren={thread.children.length > 0}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
|
||||
/>
|
||||
|
||||
<Show when={expanded() && thread.children.length > 0}>
|
||||
<For each={thread.children}>
|
||||
{(child, index) => (
|
||||
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
<Show when={expanded() && thread.children.length > 0}>
|
||||
<For each={thread.children}>
|
||||
{(child, index) => (
|
||||
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useI18n } from "../../lib/i18n"
|
||||
interface ContextUsagePanelProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
||||
const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
|
||||
|
||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
@@ -31,26 +31,16 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
|
||||
const inputTokens = createMemo(() => info().inputTokens ?? 0)
|
||||
const outputTokens = createMemo(() => info().outputTokens ?? 0)
|
||||
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
|
||||
const availableTokens = createMemo(() => info().contextAvailableTokens)
|
||||
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
|
||||
const costValue = createMemo(() => {
|
||||
const value = info().isSubscriptionModel ? 0 : info().cost
|
||||
return value > 0 ? value : 0
|
||||
})
|
||||
|
||||
|
||||
const formatTokenValue = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return t("contextUsagePanel.unavailable")
|
||||
return formatTokenTotal(value)
|
||||
}
|
||||
|
||||
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
|
||||
|
||||
return (
|
||||
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
|
||||
<div class={`session-context-panel px-4 py-2 ${props.class ?? ""}`}>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
||||
@@ -64,18 +54,6 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class={headingClass}>{t("contextUsagePanel.headings.context")}</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.used")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.available")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, For, createMemo, createEffect, type Component } from "solid-js"
|
||||
import { Show, For, createMemo, createEffect, on, type Component } from "solid-js"
|
||||
import { Expand } from "lucide-solid"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
@@ -112,6 +112,43 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
if (!props.isActive) return
|
||||
scheduleScrollToBottom()
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.isActive,
|
||||
(isActive) => {
|
||||
if (!isActive) return
|
||||
|
||||
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
|
||||
if (typeof document === "undefined") return
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
const activeIsInput =
|
||||
activeEl?.tagName === "INPUT" ||
|
||||
activeEl?.tagName === "TEXTAREA" ||
|
||||
activeEl?.tagName === "SELECT" ||
|
||||
Boolean(activeEl?.isContentEditable)
|
||||
if (activeIsInput) return
|
||||
|
||||
const modalOpen = Boolean(document.querySelector('[role="dialog"][aria-modal="true"]'))
|
||||
if (modalOpen) return
|
||||
|
||||
// Defer until the session pane is visible and the textarea is mounted.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = rootRef?.querySelector<HTMLTextAreaElement>(".prompt-input")
|
||||
if (!textarea) return
|
||||
if (textarea.disabled) return
|
||||
|
||||
try {
|
||||
textarea.focus({ preventScroll: true } as any)
|
||||
} catch {
|
||||
textarea.focus()
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
39
packages/ui/src/components/theme-mode-toggle.tsx
Normal file
39
packages/ui/src/components/theme-mode-toggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import { Laptop, Moon, Sun } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { useTheme } from "../lib/theme"
|
||||
|
||||
interface ThemeModeToggleProps {
|
||||
class?: string
|
||||
}
|
||||
|
||||
export const ThemeModeToggle: Component<ThemeModeToggleProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { themeMode, cycleThemeMode } = useTheme()
|
||||
|
||||
const modeLabel = () => {
|
||||
const mode = themeMode()
|
||||
if (mode === "system") return t("theme.mode.system")
|
||||
if (mode === "light") return t("theme.mode.light")
|
||||
return t("theme.mode.dark")
|
||||
}
|
||||
|
||||
const icon = createMemo(() => {
|
||||
const mode = themeMode()
|
||||
if (mode === "system") return <Laptop class="w-4 h-4" />
|
||||
if (mode === "light") return <Sun class="w-4 h-4" />
|
||||
return <Moon class="w-4 h-4" />
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={props.class ?? "new-tab-button"}
|
||||
onClick={cycleThemeMode}
|
||||
aria-label={t("theme.toggle.ariaLabel", { mode: modeLabel() })}
|
||||
title={t("theme.toggle.title", { mode: modeLabel() })}
|
||||
>
|
||||
{icon()}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { Copy } from "lucide-solid"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionSessionId } from "../types/permission"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
@@ -59,6 +61,11 @@ interface ToolCallProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
onContentRendered?: () => void
|
||||
/**
|
||||
* When true, tool call starts collapsed regardless of user preferences.
|
||||
* Users can still expand/collapse manually.
|
||||
*/
|
||||
forceCollapsed?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +149,9 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||
|
||||
const defaultExpandedForTool = createMemo(() => {
|
||||
if (props.forceCollapsed) {
|
||||
return false
|
||||
}
|
||||
const prefExpanded = toolOutputDefaultExpanded()
|
||||
const toolName = toolCallMemo()?.tool || ""
|
||||
if (toolName === "read") {
|
||||
@@ -575,12 +585,29 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
toolCall: toolCallMemo,
|
||||
toolState,
|
||||
toolName,
|
||||
instanceId: props.instanceId,
|
||||
sessionId: props.sessionId,
|
||||
t,
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown: renderMarkdownContent,
|
||||
renderAnsi: renderAnsiContent,
|
||||
renderDiff: renderDiffContent,
|
||||
renderToolCall: (options) => {
|
||||
if (!options?.toolCall) return null
|
||||
return (
|
||||
<ToolCall
|
||||
toolCall={options.toolCall}
|
||||
toolCallId={options.toolCall.id}
|
||||
messageId={options.messageId}
|
||||
messageVersion={options.messageVersion}
|
||||
partVersion={options.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={options.sessionId}
|
||||
forceCollapsed={options.forceCollapsed}
|
||||
/>
|
||||
)
|
||||
},
|
||||
scrollHelpers,
|
||||
}
|
||||
|
||||
@@ -634,6 +661,19 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return getToolName(currentTool)
|
||||
}
|
||||
|
||||
const headerText = createMemo(() => {
|
||||
// Keep this as a memo so copy always matches what's rendered.
|
||||
return renderToolTitle()
|
||||
})
|
||||
|
||||
const handleCopyHeader = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const text = headerText()
|
||||
if (!text) return
|
||||
await copyToClipboard(text)
|
||||
}
|
||||
|
||||
const renderToolBody = () => {
|
||||
return renderer().renderBody(rendererContext)
|
||||
}
|
||||
@@ -737,16 +777,32 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}}
|
||||
class={`tool-call ${combinedStatusClass()}`}
|
||||
>
|
||||
<button
|
||||
class="tool-call-header"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
data-status-icon={statusIcon()}
|
||||
>
|
||||
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
||||
{renderToolTitle()}
|
||||
<div class="tool-call-header">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-toggle"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
>
|
||||
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
||||
{headerText()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-copy"
|
||||
onClick={handleCopyHeader}
|
||||
aria-label={t("toolCall.header.copyAriaLabel")}
|
||||
title={t("toolCall.header.copyTitle")}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span class="tool-call-header-status" aria-hidden="true">
|
||||
{statusIcon()}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded() && (
|
||||
<div class="tool-call-details">
|
||||
|
||||
@@ -80,6 +80,19 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
return Array.isArray(draft) ? draft : []
|
||||
})
|
||||
|
||||
const hasFinalAnswers = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
if ((state as any)?.status === "completed") return true
|
||||
|
||||
const request = props.request()
|
||||
const requestAnswers = request?.questions?.map((q) => (q as any)?.answer)
|
||||
if (Array.isArray(requestAnswers) && requestAnswers.length > 0) {
|
||||
return requestAnswers.every((row) => Array.isArray(row) && row.length > 0)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||
if (!props.active()) return
|
||||
props.setDraftAnswers((prev) => {
|
||||
@@ -119,9 +132,16 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
if (!multi) {
|
||||
// When switching a radio to custom, clear existing selection first.
|
||||
updateAnswer(questionIndex, [])
|
||||
toggleOption(questionIndex, value)
|
||||
return
|
||||
}
|
||||
|
||||
toggleOption(questionIndex, value)
|
||||
// For multi-select, focusing the input should never toggle an existing custom value off.
|
||||
// Ensure the current input value is selected; removal is handled by unchecking Custom.
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
if (!existing.includes(value)) {
|
||||
updateAnswer(questionIndex, [...existing, value])
|
||||
}
|
||||
}
|
||||
|
||||
const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => {
|
||||
@@ -170,22 +190,11 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
|
||||
return (
|
||||
<Show when={isVisible() && questions().length > 0}>
|
||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">
|
||||
{props.active()
|
||||
? t("toolCall.question.status.required")
|
||||
: props.request()
|
||||
? t("toolCall.question.status.queued")
|
||||
: t("toolCall.question.status.questions")}
|
||||
</span>
|
||||
<span class="tool-call-permission-type">
|
||||
{questions().length === 1 ? t("toolCall.question.type.one") : t("toolCall.question.type.other")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`tool-call-permission p-0 gap-2 ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"} ${hasFinalAnswers() ? "tool-call-permission-answered" : ""}`}
|
||||
>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const i = () => index()
|
||||
@@ -199,9 +208,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
const customChecked = () => customValue().length > 0
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||
<div class="border border-base bg-surface-secondary p-3 text-primary">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs">
|
||||
<div class="text-sm text-primary">
|
||||
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
|
||||
</div>
|
||||
<Show when={multi()}>
|
||||
@@ -209,7 +218,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||
<div class="mt-1 text-sm font-medium text-primary">{q?.question}</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1">
|
||||
<For each={q?.options ?? []}>
|
||||
@@ -226,12 +235,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
class="mt-0.5 accent-[var(--accent-primary)]"
|
||||
checked={checked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={() => toggleOption(i(), opt.label)}
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm leading-tight">{opt.label}</div>
|
||||
<div class="text-sm leading-tight text-primary">{opt.label}</div>
|
||||
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -249,6 +259,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
class="mt-0.5 accent-[var(--accent-primary)]"
|
||||
checked={customChecked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={(e) => {
|
||||
@@ -266,13 +277,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="text-sm leading-tight">{t("toolCall.question.custom.label")}</div>
|
||||
<div class="text-sm leading-tight text-primary">{t("toolCall.question.custom.label")}</div>
|
||||
<input
|
||||
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||
class="w-full rounded-md border border-base bg-surface-base px-2 py-1 text-sm text-primary"
|
||||
type="text"
|
||||
placeholder={t("toolCall.question.custom.placeholder")}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
onFocus={(e) => {
|
||||
if (!props.active()) return
|
||||
// Keep the radio/checkbox selected while editing.
|
||||
@@ -296,7 +307,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</For>
|
||||
|
||||
<Show when={props.active()}>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-actions px-3 pb-3">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
@@ -330,7 +341,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
</Show>
|
||||
|
||||
<Show when={!props.active() && props.request()}>
|
||||
<p class="tool-call-permission-queued-text">{t("toolCall.question.queuedText")}</p>
|
||||
<p class="tool-call-permission-queued-text px-3 pb-3">{t("toolCall.question.queuedText")}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { resolveTitleForTool } from "../tool-title"
|
||||
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||
import { loadMessages } from "../../../stores/session-api"
|
||||
import { loading, messagesLoaded } from "../../../stores/session-state"
|
||||
|
||||
interface TaskSummaryItem {
|
||||
id: string
|
||||
@@ -14,6 +17,70 @@ interface TaskSummaryItem {
|
||||
title?: string
|
||||
}
|
||||
|
||||
function extractSessionIdFromTaskState(state?: ToolState): string {
|
||||
if (!state) return ""
|
||||
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
|
||||
const directId = (metadata as any)?.sessionId ?? (metadata as any)?.sessionID
|
||||
return typeof directId === "string" ? directId : ""
|
||||
}
|
||||
|
||||
function splitToolKey(key: string): { messageId: string; partId: string } | null {
|
||||
const separator = "::"
|
||||
const index = key.lastIndexOf(separator)
|
||||
if (index <= 0) return null
|
||||
const messageId = key.slice(0, index)
|
||||
const partId = key.slice(index + separator.length)
|
||||
if (!messageId || !partId) return null
|
||||
return { messageId, partId }
|
||||
}
|
||||
|
||||
function TaskToolCallRow(props: {
|
||||
toolKey: string
|
||||
store: ReturnType<typeof messageStoreBus.getOrCreate>
|
||||
sessionId: string
|
||||
renderToolCall: NonNullable<import("../types").ToolRendererContext["renderToolCall"]>
|
||||
}) {
|
||||
const parts = createMemo(() => splitToolKey(props.toolKey))
|
||||
const messageId = createMemo(() => parts()?.messageId ?? "")
|
||||
const partId = createMemo(() => parts()?.partId ?? "")
|
||||
|
||||
const record = createMemo(() => {
|
||||
const id = messageId()
|
||||
if (!id) return undefined
|
||||
return props.store.getMessage(id)
|
||||
})
|
||||
|
||||
const partEntry = createMemo(() => {
|
||||
const rec = record()
|
||||
const pid = partId()
|
||||
if (!rec || !pid) return undefined
|
||||
return rec.parts?.[pid]
|
||||
})
|
||||
|
||||
const toolPart = createMemo(() => {
|
||||
const data = partEntry()?.data
|
||||
return data && (data as any).type === "tool" ? (data as any) : undefined
|
||||
})
|
||||
|
||||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||
|
||||
const rendered = createMemo(() => {
|
||||
const part = toolPart()
|
||||
if (!part) return null
|
||||
return props.renderToolCall({
|
||||
toolCall: part as any,
|
||||
messageId: messageId(),
|
||||
messageVersion: messageVersion(),
|
||||
partVersion: partVersion(),
|
||||
sessionId: props.sessionId,
|
||||
forceCollapsed: true,
|
||||
})
|
||||
})
|
||||
|
||||
return <>{rendered()}</>
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
||||
return status
|
||||
@@ -78,7 +145,63 @@ export const taskRenderer: ToolRenderer = {
|
||||
const { input } = readToolStatePayload(state)
|
||||
return describeTaskTitle(input)
|
||||
},
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
||||
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
||||
|
||||
const childSessionId = createMemo(() => {
|
||||
const state = toolState()
|
||||
return extractSessionIdFromTaskState(state)
|
||||
})
|
||||
|
||||
const childSessionLoaded = createMemo(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return false
|
||||
const loadedForInstance = messagesLoaded().get(instanceId)
|
||||
return loadedForInstance?.has(id) ?? false
|
||||
})
|
||||
|
||||
const childSessionLoading = createMemo(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return false
|
||||
const loadingSet = loading().loadingMessages.get(instanceId)
|
||||
return loadingSet?.has(id) ?? false
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return
|
||||
if (requestedChildLoad()) return
|
||||
if (childSessionLoaded()) return
|
||||
if (childSessionLoading()) return
|
||||
setRequestedChildLoad(true)
|
||||
void loadMessages(instanceId, id)
|
||||
})
|
||||
|
||||
const childToolKeys = createMemo(() => {
|
||||
const id = childSessionId()
|
||||
if (!id) return [] as string[]
|
||||
if (!childSessionLoaded()) return [] as string[]
|
||||
|
||||
// React to session changes, but do the scan untracked to avoid
|
||||
// subscribing to every message/part node in the store.
|
||||
store.getSessionRevision(id)
|
||||
return untrack(() => {
|
||||
const messageIds = store.getSessionMessageIds(id)
|
||||
const keys: string[] = []
|
||||
for (const messageId of messageIds) {
|
||||
const record = store.getMessage(messageId)
|
||||
if (!record) continue
|
||||
for (const partId of record.partIds) {
|
||||
const entry = record.parts?.[partId]
|
||||
const data = entry?.data
|
||||
if (!data || (data as any).type !== "tool") continue
|
||||
keys.push(`${messageId}::${partId}`)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
})
|
||||
})
|
||||
const promptContent = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
@@ -123,7 +246,7 @@ export const taskRenderer: ToolRenderer = {
|
||||
return null
|
||||
})
|
||||
|
||||
const items = createMemo(() => {
|
||||
const legacyItems = createMemo(() => {
|
||||
// Track the reactive change points so we only recompute when the part/message changes
|
||||
messageVersion?.()
|
||||
partVersion?.()
|
||||
@@ -131,6 +254,9 @@ export const taskRenderer: ToolRenderer = {
|
||||
const state = toolState()
|
||||
if (!state) return []
|
||||
|
||||
// Prefer deriving steps from the child session when loaded.
|
||||
if (childSessionLoaded()) return []
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
||||
|
||||
@@ -167,51 +293,84 @@ export const taskRenderer: ToolRenderer = {
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={items().length > 0}>
|
||||
<Show when={childToolKeys().length > 0 || legacyItems().length > 0}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">{t("toolCall.task.sections.steps")}</span>
|
||||
<span class="tool-call-task-section-meta">{t("toolCall.task.steps.count", { count: items().length })}</span>
|
||||
<span class="tool-call-task-section-meta">
|
||||
{t("toolCall.task.steps.count", { count: childToolKeys().length > 0 ? childToolKeys().length : legacyItems().length })}
|
||||
</span>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
<Show
|
||||
when={childToolKeys().length > 0}
|
||||
fallback={
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={legacyItems()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusKey = summarizeStatusLabel(status)
|
||||
const statusLabel = statusKey
|
||||
? t(`toolCall.status.${statusKey}`)
|
||||
: t("toolCall.status.unknown")
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusKey = summarizeStatusLabel(status)
|
||||
const statusLabel = statusKey
|
||||
? t(`toolCall.status.${statusKey}`)
|
||||
: t("toolCall.status.unknown")
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={childToolKeys()}>
|
||||
{(key) => (
|
||||
<Show when={renderToolCall}>
|
||||
{(render) => (
|
||||
<TaskToolCallRow
|
||||
toolKey={key}
|
||||
store={store}
|
||||
sessionId={childSessionId()}
|
||||
renderToolCall={render()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
@@ -74,12 +74,15 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
||||
toolCall: toolCallAccessor,
|
||||
toolState: toolStateAccessor,
|
||||
toolName: toolNameAccessor,
|
||||
instanceId: "",
|
||||
sessionId: "",
|
||||
t,
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown,
|
||||
renderAnsi,
|
||||
renderDiff,
|
||||
renderToolCall: () => null,
|
||||
scrollHelpers: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,26 @@ export interface ToolRendererContext {
|
||||
toolCall: Accessor<ToolCallPart>
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
toolName: Accessor<string>
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
messageVersion?: Accessor<number | undefined>
|
||||
partVersion?: Accessor<number | undefined>
|
||||
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
||||
renderAnsi(options: AnsiRenderOptions): JSXElement | null
|
||||
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null
|
||||
/**
|
||||
* Render another tool call inline. This is provided by the ToolCall shell
|
||||
* to avoid renderer-level imports that would create cyclic dependencies.
|
||||
*/
|
||||
renderToolCall?: (options: {
|
||||
toolCall: ToolCallPart
|
||||
messageId?: string
|
||||
messageVersion?: number
|
||||
partVersion?: number
|
||||
sessionId: string
|
||||
forceCollapsed?: boolean
|
||||
}) => JSXElement | null
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let lastWorkspaceId: string | null = null
|
||||
let lastQuery = ""
|
||||
let lastCommandQuery = ""
|
||||
let inflightWorkspaceId: string | null = null
|
||||
let inflightSnapshotPromise: Promise<FileItem[]> | null = null
|
||||
let activeRequestId = 0
|
||||
@@ -243,6 +244,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
setLoadingState("idle")
|
||||
lastWorkspaceId = null
|
||||
lastQuery = ""
|
||||
lastCommandQuery = ""
|
||||
activeRequestId = 0
|
||||
}
|
||||
|
||||
@@ -273,8 +275,6 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
if (mode() !== "mention") return
|
||||
@@ -303,6 +303,37 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
if (mode() !== "command") return
|
||||
|
||||
const query = props.searchQuery
|
||||
const count = filteredCommands().length
|
||||
|
||||
if (query !== lastCommandQuery) {
|
||||
lastCommandQuery = query
|
||||
setSelectedIndex(0)
|
||||
resetScrollPosition()
|
||||
return
|
||||
}
|
||||
|
||||
if (count <= 0) {
|
||||
if (selectedIndex() !== 0) {
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const current = selectedIndex()
|
||||
if (current < 0) {
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
if (current >= count) {
|
||||
setSelectedIndex(count - 1)
|
||||
}
|
||||
})
|
||||
|
||||
const allItems = (): PickerItem[] => {
|
||||
const items: PickerItem[] = []
|
||||
if (mode() === "command") {
|
||||
@@ -335,20 +366,24 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
|
||||
scrollToSelected()
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
scrollToSelected()
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const selected = items[selectedIndex()]
|
||||
if (selected) {
|
||||
handleSelect(selected)
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
@@ -402,12 +437,12 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
<Show when={mode() === "command" && commandCount() > 0}>
|
||||
<div class="dropdown-section-header">{t("unifiedPicker.sections.commands")}</div>
|
||||
<For each={filteredCommands()}>
|
||||
{(command) => {
|
||||
const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name)
|
||||
{(command, index) => {
|
||||
const isSelected = () => index() === selectedIndex()
|
||||
return (
|
||||
<div
|
||||
class={`dropdown-item ${itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""}`}
|
||||
data-picker-selected={itemIndex === selectedIndex()}
|
||||
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
|
||||
data-picker-selected={isSelected()}
|
||||
onClick={() => handleSelect({ type: "command", command })}
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
|
||||
429
packages/ui/src/components/worktree-selector.tsx
Normal file
429
packages/ui/src/components/worktree-selector.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { Select } from "@kobalte/core/select"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { ChevronDown, Copy, Trash2 } from "lucide-solid"
|
||||
import type { WorktreeDescriptor } from "../../../server/src/api-types"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import {
|
||||
createWorktree,
|
||||
deleteWorktree,
|
||||
getParentSessionId,
|
||||
getGitRepoStatus,
|
||||
getWorktreeSlugForParentSession,
|
||||
getWorktrees,
|
||||
reloadWorktreeMap,
|
||||
reloadWorktrees,
|
||||
setWorktreeSlugForParentSession,
|
||||
} from "../stores/worktrees"
|
||||
import { sessions } from "../stores/sessions"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type WorktreeOption =
|
||||
| { kind: "action"; key: "__create__"; label: string }
|
||||
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
||||
|
||||
const CREATE_OPTION: WorktreeOption = { kind: "action", key: "__create__", label: "+ Create worktree" }
|
||||
|
||||
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||
// Prevent Select.Item from treating this as a selection.
|
||||
// We intentionally prevent default to stop Kobalte's internal press handling.
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation?.()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function normalizePath(input: string): string {
|
||||
return (input ?? "").replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function relativePath(fromDir: string, toDir: string): string {
|
||||
const from = normalizePath(fromDir)
|
||||
const to = normalizePath(toDir)
|
||||
if (!from || !to) return to || from || ""
|
||||
if (from === to) return "."
|
||||
|
||||
const fromParts = from.split("/").filter(Boolean)
|
||||
const toParts = to.split("/").filter(Boolean)
|
||||
|
||||
let i = 0
|
||||
while (i < fromParts.length && i < toParts.length) {
|
||||
const a = fromParts[i]
|
||||
const b = toParts[i]
|
||||
if (!a || !b) break
|
||||
if (a.toLowerCase() !== b.toLowerCase()) break
|
||||
i++
|
||||
}
|
||||
|
||||
const up = fromParts.length - i
|
||||
const down = toParts.slice(i)
|
||||
const relParts: string[] = []
|
||||
for (let j = 0; j < up; j++) relParts.push("..")
|
||||
relParts.push(...down)
|
||||
return relParts.join("/") || "."
|
||||
}
|
||||
|
||||
interface WorktreeSelectorProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const [createOpen, setCreateOpen] = createSignal(false)
|
||||
const [createSlug, setCreateSlug] = createSignal("")
|
||||
const [isCreating, setIsCreating] = createSignal(false)
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = createSignal(false)
|
||||
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
|
||||
const [forceDelete, setForceDelete] = createSignal(false)
|
||||
const [isDeleting, setIsDeleting] = createSignal(false)
|
||||
|
||||
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
|
||||
const isChildSession = createMemo(() => Boolean(session()?.parentId))
|
||||
const parentId = createMemo(() => getParentSessionId(props.instanceId, props.sessionId))
|
||||
const currentSlug = createMemo(() => getWorktreeSlugForParentSession(props.instanceId, parentId()))
|
||||
|
||||
const gitRepoStatus = createMemo(() => getGitRepoStatus(props.instanceId))
|
||||
const worktreesUnavailable = createMemo(() => gitRepoStatus() === false)
|
||||
const dropdownDisabled = createMemo(() => isChildSession() || worktreesUnavailable())
|
||||
|
||||
const worktreeOptions = createMemo<WorktreeOption[]>(() => {
|
||||
const list = getWorktrees(props.instanceId)
|
||||
const mapped: WorktreeOption[] = list.map((wt) => ({
|
||||
kind: "worktree",
|
||||
key: wt.slug,
|
||||
slug: wt.slug,
|
||||
directory: wt.directory,
|
||||
raw: wt,
|
||||
}))
|
||||
return [CREATE_OPTION, ...mapped]
|
||||
})
|
||||
|
||||
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
|
||||
const slug = currentSlug()
|
||||
const match = worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === slug)
|
||||
if (match) return match
|
||||
// Fallback to root if mapped slug is missing.
|
||||
return worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === "root")
|
||||
})
|
||||
|
||||
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
|
||||
if (opt.slug === "root") return
|
||||
setForceDelete(false)
|
||||
setDeleteTarget(opt)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const repoRoot = createMemo(() => {
|
||||
const list = getWorktrees(props.instanceId)
|
||||
return list.find((wt) => wt.slug === "root")?.directory ?? ""
|
||||
})
|
||||
|
||||
const displayPathFor = (directory: string) => {
|
||||
const base = repoRoot()
|
||||
if (!base) return directory
|
||||
return relativePath(base, directory)
|
||||
}
|
||||
|
||||
const handleCopyPath = async (directory: string) => {
|
||||
try {
|
||||
const ok = await copyToClipboard(directory)
|
||||
showToastNotification({ message: ok ? "Copied worktree path" : "Failed to copy path", variant: ok ? "success" : "error" })
|
||||
} catch (error) {
|
||||
log.error("Failed to copy worktree path", error)
|
||||
showToastNotification({ message: "Failed to copy path", variant: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = async (value: WorktreeOption | null) => {
|
||||
if (worktreesUnavailable()) return
|
||||
if (!value) return
|
||||
if (value.kind === "action") {
|
||||
setIsOpen(false)
|
||||
setCreateSlug("")
|
||||
setCreateOpen(true)
|
||||
return
|
||||
}
|
||||
await setWorktreeSlugForParentSession(props.instanceId, parentId(), value.slug)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="sidebar-selector">
|
||||
<Select<WorktreeOption>
|
||||
open={isOpen()}
|
||||
onOpenChange={setIsOpen}
|
||||
value={selectedOption() ?? null}
|
||||
onChange={(value) => {
|
||||
void handleChange(value).catch((error) => log.warn("Failed to change worktree", error))
|
||||
}}
|
||||
options={worktreeOptions()}
|
||||
optionValue="key"
|
||||
optionTextValue={(opt) => (opt.kind === "action" ? opt.label : opt.slug)}
|
||||
placeholder="Worktree"
|
||||
disabled={dropdownDisabled()}
|
||||
itemComponent={(itemProps) => {
|
||||
const opt = itemProps.item.rawValue
|
||||
if (opt.kind === "action") {
|
||||
return (
|
||||
<Select.Item item={itemProps.item} class="selector-option worktree-selector-item">
|
||||
<div class="selector-option-content w-full">
|
||||
<Select.ItemLabel class="selector-option-label">{opt.label}</Select.ItemLabel>
|
||||
<Select.ItemDescription class="selector-option-description">New from current branch</Select.ItemDescription>
|
||||
</div>
|
||||
</Select.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.Item item={itemProps.item} class="selector-option worktree-selector-item">
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Select.ItemLabel class="selector-option-label flex-1 min-w-0 truncate">
|
||||
{opt.slug === "root" ? "Workspace" : opt.slug}
|
||||
</Select.ItemLabel>
|
||||
<Show when={opt.slug !== "root"}>
|
||||
<button
|
||||
type="button"
|
||||
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
|
||||
aria-label="Delete worktree"
|
||||
title="Delete worktree"
|
||||
onPointerDown={(event) => {
|
||||
preventSelectPress(event)
|
||||
setIsOpen(false)
|
||||
openDeleteDialog(opt)
|
||||
}}
|
||||
onPointerUp={preventSelectPress}
|
||||
onMouseDown={preventSelectPress}
|
||||
onMouseUp={preventSelectPress}
|
||||
onClick={preventSelectPress}
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="selector-option-description flex-1 min-w-0 truncate font-mono"
|
||||
title={opt.directory}
|
||||
>
|
||||
{displayPathFor(opt.directory)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
|
||||
aria-label="Copy path"
|
||||
title="Copy path"
|
||||
onPointerDown={(event) => {
|
||||
preventSelectPress(event)
|
||||
void (async () => {
|
||||
await handleCopyPath(opt.directory)
|
||||
setIsOpen(false)
|
||||
})()
|
||||
}}
|
||||
onPointerUp={preventSelectPress}
|
||||
onMouseDown={preventSelectPress}
|
||||
onMouseUp={preventSelectPress}
|
||||
onClick={preventSelectPress}
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Select.Item>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="selector-trigger">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Select.Value<WorktreeOption>>
|
||||
{(state) => {
|
||||
if (worktreesUnavailable()) {
|
||||
return (
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">Worktree: Unavailable</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const value = state.selectedOption()
|
||||
const label = value && value.kind === "worktree" ? (value.slug === "root" ? "Workspace" : value.slug) : "Workspace"
|
||||
return (
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">Worktree: {label}</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</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 max-h-80 overflow-auto p-1">
|
||||
<Select.Listbox class="selector-listbox" />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
|
||||
<Dialog open={createOpen()} onOpenChange={(open) => !open && setCreateOpen(false)}>
|
||||
<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-md p-6 flex flex-col gap-5">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Create worktree</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">Creates a git worktree</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-muted uppercase tracking-wide">Name</label>
|
||||
<input
|
||||
class="form-input w-full"
|
||||
value={createSlug()}
|
||||
onInput={(e) => setCreateSlug(e.currentTarget.value)}
|
||||
placeholder="worktree-name"
|
||||
disabled={isCreating()}
|
||||
spellcheck={false}
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={isCreating()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-primary"
|
||||
disabled={
|
||||
isCreating() ||
|
||||
!createSlug().trim() ||
|
||||
createSlug().trim() === "root" ||
|
||||
/[\x00-\x1F\x7F]/.test(createSlug())
|
||||
}
|
||||
onClick={() => {
|
||||
const slug = createSlug().trim()
|
||||
void (async () => {
|
||||
setIsCreating(true)
|
||||
await createWorktree(props.instanceId, slug)
|
||||
await reloadWorktrees(props.instanceId)
|
||||
await setWorktreeSlugForParentSession(props.instanceId, parentId(), slug)
|
||||
setCreateOpen(false)
|
||||
showToastNotification({ message: `Created worktree ${slug}`, variant: "success" })
|
||||
})()
|
||||
.catch((error) => {
|
||||
log.warn("Failed to create worktree", error)
|
||||
showToastNotification({
|
||||
message: error instanceof Error ? error.message : "Failed to create worktree",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setIsCreating(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isCreating() ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
|
||||
<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-md p-6 flex flex-col gap-5">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">Removes the git worktree checkout directory for this branch.</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<Show when={deleteTarget()}>
|
||||
{(target) => (
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
|
||||
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceDelete()}
|
||||
onChange={(e) => setForceDelete(e.currentTarget.checked)}
|
||||
disabled={isDeleting()}
|
||||
/>
|
||||
Force delete (discard local changes)
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
disabled={isDeleting()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-primary"
|
||||
disabled={isDeleting() || !deleteTarget()}
|
||||
onClick={() => {
|
||||
const target = deleteTarget()
|
||||
if (!target) {
|
||||
setDeleteOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
setIsDeleting(true)
|
||||
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
|
||||
await reloadWorktrees(props.instanceId)
|
||||
await reloadWorktreeMap(props.instanceId)
|
||||
|
||||
if (currentSlug() === target.slug) {
|
||||
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
|
||||
}
|
||||
|
||||
setDeleteOpen(false)
|
||||
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
|
||||
})()
|
||||
.catch((error) => {
|
||||
log.warn("Failed to delete worktree", error)
|
||||
showToastNotification({
|
||||
message: error instanceof Error ? error.message : "Failed to delete worktree",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDeleting(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isDeleting() ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,12 +20,14 @@ import type {
|
||||
WorkspaceLogEntry,
|
||||
WorkspaceEventPayload,
|
||||
WorkspaceEventType,
|
||||
WorktreeListResponse,
|
||||
WorktreeMap,
|
||||
WorktreeCreateRequest,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
|
||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE ?? FALLBACK_API_BASE : FALLBACK_API_BASE
|
||||
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined
|
||||
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
||||
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
||||
@@ -127,6 +129,39 @@ export const serverApi = {
|
||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
||||
},
|
||||
|
||||
fetchWorktrees(id: string): Promise<WorktreeListResponse> {
|
||||
return request<WorktreeListResponse>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`)
|
||||
},
|
||||
|
||||
createWorktree(id: string, payload: WorktreeCreateRequest): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||
return request<{ slug: string; directory: string; branch?: string }>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
|
||||
deleteWorktree(id: string, slug: string, options?: { force?: boolean }): Promise<void> {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.force) {
|
||||
params.set("force", "true")
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : ""
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}${suffix}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
},
|
||||
|
||||
readWorktreeMap(id: string): Promise<WorktreeMap> {
|
||||
return request<WorktreeMap>(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`)
|
||||
},
|
||||
|
||||
writeWorktreeMap(id: string, map: WorktreeMap): Promise<void> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(map),
|
||||
})
|
||||
},
|
||||
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
|
||||
return request<WorkspaceDescriptor>("/api/workspaces", {
|
||||
method: "POST",
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface UseCommandsOptions {
|
||||
toggleShowTimelineTools: () => void
|
||||
toggleUsageMetrics: () => void
|
||||
toggleAutoCleanupBlankSessions: () => void
|
||||
togglePromptSubmitOnEnter: () => void
|
||||
setDiffViewMode: (mode: "split" | "unified") => void
|
||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||
@@ -423,6 +424,18 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "prompt-submit-shortcut",
|
||||
label: () =>
|
||||
options.preferences().promptSubmitOnEnter
|
||||
? tGlobal("commands.promptSubmitShortcut.label.swapped")
|
||||
: tGlobal("commands.promptSubmitShortcut.label.default"),
|
||||
description: () => tGlobal("commands.promptSubmitShortcut.description"),
|
||||
category: "Input & Focus",
|
||||
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
|
||||
action: options.togglePromptSubmitOnEnter,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "thinking",
|
||||
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
|
||||
|
||||
@@ -29,4 +29,10 @@ export const appMessages = {
|
||||
|
||||
"releases.uiUpdated.title": "UI updated",
|
||||
"releases.uiUpdated.message": "UI is now updated to {version}.",
|
||||
|
||||
"theme.mode.system": "System",
|
||||
"theme.mode.light": "Light",
|
||||
"theme.mode.dark": "Dark",
|
||||
"theme.toggle.title": "Theme: {mode}",
|
||||
"theme.toggle.ariaLabel": "Theme: {mode}",
|
||||
} as const
|
||||
|
||||
@@ -82,9 +82,14 @@ export const commandMessages = {
|
||||
"commands.clearInput.description": "Clear the prompt textarea",
|
||||
"commands.clearInput.keywords": "clear, reset",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "Show Thinking Blocks",
|
||||
"commands.thinkingBlocks.label.hide": "Hide Thinking Blocks",
|
||||
"commands.thinkingBlocks.description": "Show/hide AI thinking process",
|
||||
"commands.promptSubmitShortcut.label.default": "Enter: New Line, Cmd/Ctrl+Enter: Submit Prompt",
|
||||
"commands.promptSubmitShortcut.label.swapped": "Enter: Submit Prompt, Cmd/Ctrl+Enter: New Line",
|
||||
"commands.promptSubmitShortcut.description": "Swap Enter and Cmd/Ctrl+Enter behavior in the prompt input",
|
||||
"commands.promptSubmitShortcut.keywords": "enter, cmd, ctrl, submit, send, newline, shortcut, keybind, prompt",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "Show Thinking",
|
||||
"commands.thinkingBlocks.label.hide": "Hide Thinking",
|
||||
"commands.thinkingBlocks.description": "Show or hide AI thinking sections",
|
||||
"commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide",
|
||||
|
||||
"commands.timelineToolCalls.label.show": "Show Timeline Tool Calls",
|
||||
@@ -99,8 +104,8 @@ export const commandMessages = {
|
||||
"commands.common.enabled": "Enabled",
|
||||
"commands.common.disabled": "Disabled",
|
||||
|
||||
"commands.thinkingBlocksDefault.label": "Thinking Blocks Default · {state}",
|
||||
"commands.thinkingBlocksDefault.description": "Toggle whether thinking blocks start expanded",
|
||||
"commands.thinkingBlocksDefault.label": "Thinking View: {state}",
|
||||
"commands.thinkingBlocksDefault.description": "Collapse / Expand AI thinking sections when shown",
|
||||
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default",
|
||||
|
||||
"commands.diffViewSplit.label": "Use Split Diff View",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
||||
"messageSection.quote.addAsQuote": "Add as quote",
|
||||
"messageSection.quote.addAsCode": "Add as code",
|
||||
"messageSection.quote.copy": "Copy",
|
||||
"messageSection.quote.copied": "Copied!",
|
||||
"messageSection.quote.copyFailed": "Copy failed",
|
||||
|
||||
"messageTimeline.ariaLabel": "Message timeline",
|
||||
"messageTimeline.segment.user.label": "You",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Go to Session",
|
||||
"messageBlock.tool.goToSession.title": "Go to session",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
|
||||
"messageBlock.tool.deletePart.label": "Delete",
|
||||
"messageBlock.tool.deletePart.deleting": "Deleting...",
|
||||
"messageBlock.tool.deletePart.title": "Delete this tool call output",
|
||||
"messageBlock.tool.deletePart.failed.title": "Delete failed",
|
||||
"messageBlock.tool.deletePart.failed.message": "Failed to delete tool call output",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "Session compaction",
|
||||
"messageBlock.compaction.autoLabel": "Session auto-compacted",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "Generating...",
|
||||
"messageItem.status.sending": "Sending...",
|
||||
"messageItem.status.failedToSend": "Message failed to send",
|
||||
"messagePart.actions.delete": "Delete",
|
||||
"messagePart.actions.deleting": "Deleting...",
|
||||
"messagePart.actions.deleteTitle": "Delete this item",
|
||||
"messagePart.actions.deleteFailedTitle": "Delete failed",
|
||||
"messagePart.actions.deleteFailedMessage": "Failed to delete item",
|
||||
"messageItem.attachment.defaultName": "attachment",
|
||||
"messageItem.attachment.downloadAriaLabel": "Download {name}",
|
||||
"messageItem.agentMeta.agentLabel": "Agent: {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "Session ID copied",
|
||||
"sessionList.copyId.error": "Unable to copy session ID",
|
||||
"sessionList.delete.error": "Unable to delete session",
|
||||
"sessionList.delete.title": "Delete session",
|
||||
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",
|
||||
"sessionList.delete.confirmLabel": "Delete",
|
||||
"sessionList.delete.cancelLabel": "Cancel",
|
||||
"sessionList.rename.error": "Unable to rename session",
|
||||
|
||||
"sessionList.filter.placeholder": "Search sessions…",
|
||||
"sessionList.filter.ariaLabel": "Search sessions",
|
||||
"sessionList.selection.selectAllLabel": "Select all",
|
||||
"sessionList.selection.selectAllAriaLabel": "Select all sessions",
|
||||
"sessionList.selection.clearLabel": "Clear",
|
||||
"sessionList.selection.clearAriaLabel": "Clear selection",
|
||||
"sessionList.selection.checkboxAriaLabel": "Select session",
|
||||
"sessionList.bulkDelete.button": "Delete {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "Delete {count} selected sessions",
|
||||
"sessionList.bulkDelete.title": "Delete sessions",
|
||||
"sessionList.bulkDelete.confirmMessage": "Delete {count} selected sessions? This cannot be undone.",
|
||||
"sessionList.bulkDelete.confirmLabel": "Delete",
|
||||
"sessionList.bulkDelete.cancelLabel": "Cancel",
|
||||
"sessionList.bulkDelete.error": "Unable to delete {count} sessions",
|
||||
|
||||
"sessionRenameDialog.title": "Rename Session",
|
||||
"sessionRenameDialog.description.withLabel": "Update the title for \"{label}\".",
|
||||
"sessionRenameDialog.description.default": "Set a new title for this session.",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "Waiting to run...",
|
||||
"toolCall.error.label": "Error:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
||||
|
||||
@@ -82,9 +82,14 @@ export const commandMessages = {
|
||||
"commands.clearInput.description": "Borrar el área de texto del prompt",
|
||||
"commands.clearInput.keywords": "limpiar, reiniciar",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "Mostrar bloques de pensamiento",
|
||||
"commands.thinkingBlocks.label.hide": "Ocultar bloques de pensamiento",
|
||||
"commands.thinkingBlocks.description": "Mostrar/ocultar el proceso de pensamiento de la IA",
|
||||
"commands.promptSubmitShortcut.label.default": "Enter: Nueva linea, Cmd/Ctrl+Enter: Enviar prompt",
|
||||
"commands.promptSubmitShortcut.label.swapped": "Enter: Enviar prompt, Cmd/Ctrl+Enter: Nueva linea",
|
||||
"commands.promptSubmitShortcut.description": "Intercambiar el comportamiento de Enter y Cmd/Ctrl+Enter en la entrada de prompt",
|
||||
"commands.promptSubmitShortcut.keywords": "enter, enviar, salto de linea, atajo, teclado, cmd, ctrl, prompt",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "Mostrar pensamiento",
|
||||
"commands.thinkingBlocks.label.hide": "Ocultar pensamiento",
|
||||
"commands.thinkingBlocks.description": "Mostrar u ocultar secciones de pensamiento de IA",
|
||||
"commands.thinkingBlocks.keywords": "pensamiento, razonamiento, alternar, mostrar, ocultar",
|
||||
|
||||
"commands.timelineToolCalls.label.show": "Mostrar llamadas de herramienta en la línea de tiempo",
|
||||
@@ -99,8 +104,8 @@ export const commandMessages = {
|
||||
"commands.common.enabled": "Activado",
|
||||
"commands.common.disabled": "Desactivado",
|
||||
|
||||
"commands.thinkingBlocksDefault.label": "Bloques de pensamiento por defecto · {state}",
|
||||
"commands.thinkingBlocksDefault.description": "Alternar si los bloques de pensamiento empiezan expandidos",
|
||||
"commands.thinkingBlocksDefault.label": "Vista de pensamiento: {state}",
|
||||
"commands.thinkingBlocksDefault.description": "Contraer / Expandir secciones de pensamiento de IA cuando se muestran",
|
||||
"commands.thinkingBlocksDefault.keywords": "pensamiento, razonamiento, expandir, colapsar, por defecto",
|
||||
|
||||
"commands.diffViewSplit.label": "Usar vista de diff dividida",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
||||
"messageSection.quote.addAsQuote": "Añadir como cita",
|
||||
"messageSection.quote.addAsCode": "Añadir como código",
|
||||
"messageSection.quote.copy": "Copiar",
|
||||
"messageSection.quote.copied": "¡Copiado!",
|
||||
"messageSection.quote.copyFailed": "No se pudo copiar",
|
||||
|
||||
"messageTimeline.ariaLabel": "Línea de tiempo de mensajes",
|
||||
"messageTimeline.segment.user.label": "Tú",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Ir a sesión",
|
||||
"messageBlock.tool.goToSession.title": "Ir a la sesión",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible",
|
||||
"messageBlock.tool.deletePart.label": "Eliminar",
|
||||
"messageBlock.tool.deletePart.deleting": "Eliminando...",
|
||||
"messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta",
|
||||
"messageBlock.tool.deletePart.failed.title": "Error al eliminar",
|
||||
"messageBlock.tool.deletePart.failed.message": "No se pudo eliminar la salida de herramienta",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "Compactación de sesión",
|
||||
"messageBlock.compaction.autoLabel": "Sesión compactada automáticamente",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "Generando...",
|
||||
"messageItem.status.sending": "Enviando...",
|
||||
"messageItem.status.failedToSend": "No se pudo enviar el mensaje",
|
||||
"messagePart.actions.delete": "Eliminar",
|
||||
"messagePart.actions.deleting": "Eliminando...",
|
||||
"messagePart.actions.deleteTitle": "Eliminar este elemento",
|
||||
"messagePart.actions.deleteFailedTitle": "Error al eliminar",
|
||||
"messagePart.actions.deleteFailedMessage": "No se pudo eliminar el elemento",
|
||||
"messageItem.attachment.defaultName": "adjunto",
|
||||
"messageItem.attachment.downloadAriaLabel": "Descargar {name}",
|
||||
"messageItem.agentMeta.agentLabel": "Agente: {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "ID de sesión copiado",
|
||||
"sessionList.copyId.error": "No se pudo copiar el ID de sesión",
|
||||
"sessionList.delete.error": "No se pudo eliminar la sesión",
|
||||
"sessionList.delete.title": "Eliminar sesión",
|
||||
"sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.",
|
||||
"sessionList.delete.confirmLabel": "Eliminar",
|
||||
"sessionList.delete.cancelLabel": "Cancelar",
|
||||
"sessionList.rename.error": "No se pudo renombrar la sesión",
|
||||
|
||||
"sessionList.filter.placeholder": "Buscar sesiones…",
|
||||
"sessionList.filter.ariaLabel": "Buscar sesiones",
|
||||
"sessionList.selection.selectAllLabel": "Seleccionar todo",
|
||||
"sessionList.selection.selectAllAriaLabel": "Seleccionar todas las sesiones",
|
||||
"sessionList.selection.clearLabel": "Limpiar",
|
||||
"sessionList.selection.clearAriaLabel": "Limpiar selección",
|
||||
"sessionList.selection.checkboxAriaLabel": "Seleccionar sesión",
|
||||
"sessionList.bulkDelete.button": "Eliminar {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "Eliminar {count} sesiones seleccionadas",
|
||||
"sessionList.bulkDelete.title": "Eliminar sesiones",
|
||||
"sessionList.bulkDelete.confirmMessage": "¿Eliminar {count} sesiones seleccionadas? Esto no se puede deshacer.",
|
||||
"sessionList.bulkDelete.confirmLabel": "Eliminar",
|
||||
"sessionList.bulkDelete.cancelLabel": "Cancelar",
|
||||
"sessionList.bulkDelete.error": "No se pudieron eliminar {count} sesiones",
|
||||
|
||||
"sessionRenameDialog.title": "Renombrar sesión",
|
||||
"sessionRenameDialog.description.withLabel": "Actualiza el título de \"{label}\".",
|
||||
"sessionRenameDialog.description.default": "Establece un nuevo título para esta sesión.",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "Esperando para ejecutar...",
|
||||
"toolCall.error.label": "Error:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
||||
|
||||
@@ -82,9 +82,14 @@ export const commandMessages = {
|
||||
"commands.clearInput.description": "Effacer la zone de texte du prompt",
|
||||
"commands.clearInput.keywords": "effacer, réinitialiser, prompt",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "Afficher les blocs de réflexion",
|
||||
"commands.thinkingBlocks.label.hide": "Masquer les blocs de réflexion",
|
||||
"commands.thinkingBlocks.description": "Afficher/masquer le processus de réflexion de l'IA",
|
||||
"commands.promptSubmitShortcut.label.default": "Entree: Nouvelle ligne, Cmd/Ctrl+Entree: Envoyer le prompt",
|
||||
"commands.promptSubmitShortcut.label.swapped": "Entree: Envoyer le prompt, Cmd/Ctrl+Entree: Nouvelle ligne",
|
||||
"commands.promptSubmitShortcut.description": "Echanger le comportement de Entree et Cmd/Ctrl+Entree dans la saisie du prompt",
|
||||
"commands.promptSubmitShortcut.keywords": "entree, envoyer, nouvelle ligne, raccourci, cmd, ctrl, prompt",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "Afficher la réflexion",
|
||||
"commands.thinkingBlocks.label.hide": "Masquer la réflexion",
|
||||
"commands.thinkingBlocks.description": "Afficher ou masquer les sections de réflexion de l'IA",
|
||||
"commands.thinkingBlocks.keywords": "réflexion, raisonnement, basculer, afficher, masquer",
|
||||
|
||||
"commands.timelineToolCalls.label.show": "Afficher les appels d'outil dans la timeline",
|
||||
@@ -99,8 +104,8 @@ export const commandMessages = {
|
||||
"commands.common.enabled": "Activé",
|
||||
"commands.common.disabled": "Désactivé",
|
||||
|
||||
"commands.thinkingBlocksDefault.label": "Blocs de réflexion par défaut · {state}",
|
||||
"commands.thinkingBlocksDefault.description": "Choisir si les blocs de réflexion démarrent développés",
|
||||
"commands.thinkingBlocksDefault.label": "Vue de la réflexion: {state}",
|
||||
"commands.thinkingBlocksDefault.description": "Réduire / Développer les sections de réflexion de l'IA lorsqu'elles sont affichées",
|
||||
"commands.thinkingBlocksDefault.keywords": "réflexion, raisonnement, développer, réduire, défaut",
|
||||
|
||||
"commands.diffViewSplit.label": "Utiliser la vue diff côte à côte",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
|
||||
"messageSection.quote.addAsQuote": "Ajouter en citation",
|
||||
"messageSection.quote.addAsCode": "Ajouter en code",
|
||||
"messageSection.quote.copy": "Copier",
|
||||
"messageSection.quote.copied": "Copié !",
|
||||
"messageSection.quote.copyFailed": "Impossible de copier",
|
||||
|
||||
"messageTimeline.ariaLabel": "Chronologie des messages",
|
||||
"messageTimeline.segment.user.label": "Vous",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Aller à la session",
|
||||
"messageBlock.tool.goToSession.title": "Aller à la session",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible",
|
||||
"messageBlock.tool.deletePart.label": "Supprimer",
|
||||
"messageBlock.tool.deletePart.deleting": "Suppression...",
|
||||
"messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil",
|
||||
"messageBlock.tool.deletePart.failed.title": "Échec de suppression",
|
||||
"messageBlock.tool.deletePart.failed.message": "Impossible de supprimer la sortie d'outil",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "Compaction de la session",
|
||||
"messageBlock.compaction.autoLabel": "Session compactée automatiquement",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "Génération...",
|
||||
"messageItem.status.sending": "Envoi...",
|
||||
"messageItem.status.failedToSend": "Échec de l'envoi du message",
|
||||
"messagePart.actions.delete": "Supprimer",
|
||||
"messagePart.actions.deleting": "Suppression...",
|
||||
"messagePart.actions.deleteTitle": "Supprimer cet élément",
|
||||
"messagePart.actions.deleteFailedTitle": "Échec de suppression",
|
||||
"messagePart.actions.deleteFailedMessage": "Impossible de supprimer l'élément",
|
||||
"messageItem.attachment.defaultName": "piece-jointe",
|
||||
"messageItem.attachment.downloadAriaLabel": "Télécharger {name}",
|
||||
"messageItem.agentMeta.agentLabel": "Agent : {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "ID de session copié",
|
||||
"sessionList.copyId.error": "Impossible de copier l'ID de session",
|
||||
"sessionList.delete.error": "Impossible de supprimer la session",
|
||||
"sessionList.delete.title": "Supprimer la session",
|
||||
"sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.",
|
||||
"sessionList.delete.confirmLabel": "Supprimer",
|
||||
"sessionList.delete.cancelLabel": "Annuler",
|
||||
"sessionList.rename.error": "Impossible de renommer la session",
|
||||
|
||||
"sessionList.filter.placeholder": "Rechercher des sessions…",
|
||||
"sessionList.filter.ariaLabel": "Rechercher des sessions",
|
||||
"sessionList.selection.selectAllLabel": "Tout sélectionner",
|
||||
"sessionList.selection.selectAllAriaLabel": "Sélectionner toutes les sessions",
|
||||
"sessionList.selection.clearLabel": "Effacer",
|
||||
"sessionList.selection.clearAriaLabel": "Effacer la sélection",
|
||||
"sessionList.selection.checkboxAriaLabel": "Sélectionner la session",
|
||||
"sessionList.bulkDelete.button": "Supprimer {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "Supprimer {count} sessions sélectionnées",
|
||||
"sessionList.bulkDelete.title": "Supprimer des sessions",
|
||||
"sessionList.bulkDelete.confirmMessage": "Supprimer {count} sessions sélectionnées ? Cette action est irréversible.",
|
||||
"sessionList.bulkDelete.confirmLabel": "Supprimer",
|
||||
"sessionList.bulkDelete.cancelLabel": "Annuler",
|
||||
"sessionList.bulkDelete.error": "Impossible de supprimer {count} sessions",
|
||||
|
||||
"sessionRenameDialog.title": "Renommer la session",
|
||||
"sessionRenameDialog.description.withLabel": "Mettre à jour le titre de \"{label}\".",
|
||||
"sessionRenameDialog.description.default": "Définir un nouveau titre pour cette session.",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "En attente d'exécution...",
|
||||
"toolCall.error.label": "Erreur :",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
||||
|
||||
@@ -82,9 +82,14 @@ export const commandMessages = {
|
||||
"commands.clearInput.description": "プロンプト入力欄をクリア",
|
||||
"commands.clearInput.keywords": "クリア, リセット, clear, reset",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "思考ブロックを表示",
|
||||
"commands.thinkingBlocks.label.hide": "思考ブロックを非表示",
|
||||
"commands.thinkingBlocks.description": "AI の思考過程を表示/非表示",
|
||||
"commands.promptSubmitShortcut.label.default": "Enter: 改行, Cmd/Ctrl+Enter: プロンプト送信",
|
||||
"commands.promptSubmitShortcut.label.swapped": "Enter: プロンプト送信, Cmd/Ctrl+Enter: 改行",
|
||||
"commands.promptSubmitShortcut.description": "プロンプト入力で Enter と Cmd/Ctrl+Enter の動作を入れ替え",
|
||||
"commands.promptSubmitShortcut.keywords": "enter, 送信, 改行, ショートカット, cmd, ctrl, プロンプト",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "思考を表示",
|
||||
"commands.thinkingBlocks.label.hide": "思考を非表示",
|
||||
"commands.thinkingBlocks.description": "AI の思考セクションを表示/非表示",
|
||||
"commands.thinkingBlocks.keywords": "思考, 推論, 切り替え, 表示, 非表示, thinking, reasoning, toggle, show, hide",
|
||||
|
||||
"commands.timelineToolCalls.label.show": "タイムラインのツールコールを表示",
|
||||
@@ -99,8 +104,8 @@ export const commandMessages = {
|
||||
"commands.common.enabled": "有効",
|
||||
"commands.common.disabled": "無効",
|
||||
|
||||
"commands.thinkingBlocksDefault.label": "思考ブロックの既定 · {state}",
|
||||
"commands.thinkingBlocksDefault.description": "思考ブロックを既定で展開するか切り替え",
|
||||
"commands.thinkingBlocksDefault.label": "思考ビュー: {state}",
|
||||
"commands.thinkingBlocksDefault.description": "表示中は AI の思考セクションを折りたたみ / 展開",
|
||||
"commands.thinkingBlocksDefault.keywords": "思考, 推論, 展開, 折りたたみ, 既定, thinking, reasoning, expand, collapse, default",
|
||||
|
||||
"commands.diffViewSplit.label": "分割 diff 表示を使用",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
|
||||
"messageSection.quote.addAsQuote": "引用として追加",
|
||||
"messageSection.quote.addAsCode": "コードとして追加",
|
||||
"messageSection.quote.copy": "コピー",
|
||||
"messageSection.quote.copied": "コピーしました",
|
||||
"messageSection.quote.copyFailed": "コピーできませんでした",
|
||||
|
||||
"messageTimeline.ariaLabel": "メッセージタイムライン",
|
||||
"messageTimeline.segment.user.label": "あなた",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "セッションへ移動",
|
||||
"messageBlock.tool.goToSession.title": "セッションへ移動",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません",
|
||||
"messageBlock.tool.deletePart.label": "削除",
|
||||
"messageBlock.tool.deletePart.deleting": "削除中...",
|
||||
"messageBlock.tool.deletePart.title": "このツール出力を削除",
|
||||
"messageBlock.tool.deletePart.failed.title": "削除に失敗しました",
|
||||
"messageBlock.tool.deletePart.failed.message": "ツール出力の削除に失敗しました",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "セッションのコンパクト化",
|
||||
"messageBlock.compaction.autoLabel": "セッションを自動でコンパクト化しました",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "生成中...",
|
||||
"messageItem.status.sending": "送信中...",
|
||||
"messageItem.status.failedToSend": "メッセージの送信に失敗しました",
|
||||
"messagePart.actions.delete": "削除",
|
||||
"messagePart.actions.deleting": "削除中...",
|
||||
"messagePart.actions.deleteTitle": "この項目を削除",
|
||||
"messagePart.actions.deleteFailedTitle": "削除に失敗しました",
|
||||
"messagePart.actions.deleteFailedMessage": "項目の削除に失敗しました",
|
||||
"messageItem.attachment.defaultName": "添付ファイル",
|
||||
"messageItem.attachment.downloadAriaLabel": "{name} をダウンロード",
|
||||
"messageItem.agentMeta.agentLabel": "エージェント: {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "セッション ID をコピーしました",
|
||||
"sessionList.copyId.error": "セッション ID をコピーできません",
|
||||
"sessionList.delete.error": "セッションを削除できません",
|
||||
"sessionList.delete.title": "セッションを削除",
|
||||
"sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。",
|
||||
"sessionList.delete.confirmLabel": "削除",
|
||||
"sessionList.delete.cancelLabel": "キャンセル",
|
||||
"sessionList.rename.error": "セッション名を変更できません",
|
||||
|
||||
"sessionList.filter.placeholder": "セッションを検索…",
|
||||
"sessionList.filter.ariaLabel": "セッションを検索",
|
||||
"sessionList.selection.selectAllLabel": "すべて選択",
|
||||
"sessionList.selection.selectAllAriaLabel": "すべてのセッションを選択",
|
||||
"sessionList.selection.clearLabel": "クリア",
|
||||
"sessionList.selection.clearAriaLabel": "選択をクリア",
|
||||
"sessionList.selection.checkboxAriaLabel": "セッションを選択",
|
||||
"sessionList.bulkDelete.button": "{count} 件を削除",
|
||||
"sessionList.bulkDelete.ariaLabel": "選択した {count} 件のセッションを削除",
|
||||
"sessionList.bulkDelete.title": "セッションを削除",
|
||||
"sessionList.bulkDelete.confirmMessage": "選択した {count} 件のセッションを削除しますか?この操作は元に戻せません。",
|
||||
"sessionList.bulkDelete.confirmLabel": "削除",
|
||||
"sessionList.bulkDelete.cancelLabel": "キャンセル",
|
||||
"sessionList.bulkDelete.error": "{count} 件のセッションを削除できません",
|
||||
|
||||
"sessionRenameDialog.title": "セッション名を変更",
|
||||
"sessionRenameDialog.description.withLabel": "\"{label}\" のタイトルを更新します。",
|
||||
"sessionRenameDialog.description.default": "このセッションの新しいタイトルを設定します。",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "実行待ち...",
|
||||
"toolCall.error.label": "エラー:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
|
||||
|
||||
@@ -82,9 +82,14 @@ export const commandMessages = {
|
||||
"commands.clearInput.description": "Очистить поле prompt",
|
||||
"commands.clearInput.keywords": "очистить, сброс",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "Показать блоки размышлений",
|
||||
"commands.thinkingBlocks.label.hide": "Скрыть блоки размышлений",
|
||||
"commands.thinkingBlocks.description": "Показать/скрыть процесс рассуждений AI",
|
||||
"commands.promptSubmitShortcut.label.default": "Enter: Новая строка, Cmd/Ctrl+Enter: Отправить промпт",
|
||||
"commands.promptSubmitShortcut.label.swapped": "Enter: Отправить промпт, Cmd/Ctrl+Enter: Новая строка",
|
||||
"commands.promptSubmitShortcut.description": "Поменять местами Enter и Cmd/Ctrl+Enter в поле ввода промпта",
|
||||
"commands.promptSubmitShortcut.keywords": "enter, отправить, новая строка, сочетание, cmd, ctrl, промпт",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "Показать размышления",
|
||||
"commands.thinkingBlocks.label.hide": "Скрыть размышления",
|
||||
"commands.thinkingBlocks.description": "Показать или скрыть секции размышлений ИИ",
|
||||
"commands.thinkingBlocks.keywords": "thinking, reasoning, переключить, показать, скрыть",
|
||||
|
||||
"commands.timelineToolCalls.label.show": "Показать Tool Calls в таймлайне",
|
||||
@@ -99,8 +104,8 @@ export const commandMessages = {
|
||||
"commands.common.enabled": "Включено",
|
||||
"commands.common.disabled": "Выключено",
|
||||
|
||||
"commands.thinkingBlocksDefault.label": "Блоки размышлений по умолчанию · {state}",
|
||||
"commands.thinkingBlocksDefault.description": "Переключить, разворачивать ли блоки размышлений по умолчанию",
|
||||
"commands.thinkingBlocksDefault.label": "Вид размышлений: {state}",
|
||||
"commands.thinkingBlocksDefault.description": "Свернуть / Развернуть секции размышлений ИИ, когда они показаны",
|
||||
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, развернуть, свернуть, по умолчанию",
|
||||
|
||||
"commands.diffViewSplit.label": "Раздельный просмотр diff",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
|
||||
"messageSection.quote.addAsQuote": "Добавить как цитату",
|
||||
"messageSection.quote.addAsCode": "Добавить как код",
|
||||
"messageSection.quote.copy": "Копировать",
|
||||
"messageSection.quote.copied": "Скопировано!",
|
||||
"messageSection.quote.copyFailed": "Не удалось скопировать",
|
||||
|
||||
"messageTimeline.ariaLabel": "Таймлайн сообщений",
|
||||
"messageTimeline.segment.user.label": "Вы",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Перейти к сессии",
|
||||
"messageBlock.tool.goToSession.title": "Перейти к сессии",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна",
|
||||
"messageBlock.tool.deletePart.label": "Удалить",
|
||||
"messageBlock.tool.deletePart.deleting": "Удаление...",
|
||||
"messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента",
|
||||
"messageBlock.tool.deletePart.failed.title": "Ошибка удаления",
|
||||
"messageBlock.tool.deletePart.failed.message": "Не удалось удалить вывод инструмента",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "Компактация сессии",
|
||||
"messageBlock.compaction.autoLabel": "Сессия автоматически компактирована",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "Генерация…",
|
||||
"messageItem.status.sending": "Отправка…",
|
||||
"messageItem.status.failedToSend": "Не удалось отправить сообщение",
|
||||
"messagePart.actions.delete": "Удалить",
|
||||
"messagePart.actions.deleting": "Удаление...",
|
||||
"messagePart.actions.deleteTitle": "Удалить этот элемент",
|
||||
"messagePart.actions.deleteFailedTitle": "Ошибка удаления",
|
||||
"messagePart.actions.deleteFailedMessage": "Не удалось удалить элемент",
|
||||
"messageItem.attachment.defaultName": "вложение",
|
||||
"messageItem.attachment.downloadAriaLabel": "Скачать {name}",
|
||||
"messageItem.agentMeta.agentLabel": "Агент: {agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "ID сессии скопирован",
|
||||
"sessionList.copyId.error": "Не удалось скопировать ID сессии",
|
||||
"sessionList.delete.error": "Не удалось удалить сессию",
|
||||
"sessionList.delete.title": "Удалить сессию",
|
||||
"sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.",
|
||||
"sessionList.delete.confirmLabel": "Удалить",
|
||||
"sessionList.delete.cancelLabel": "Отмена",
|
||||
"sessionList.rename.error": "Не удалось переименовать сессию",
|
||||
|
||||
"sessionList.filter.placeholder": "Поиск сессий…",
|
||||
"sessionList.filter.ariaLabel": "Поиск сессий",
|
||||
"sessionList.selection.selectAllLabel": "Выбрать все",
|
||||
"sessionList.selection.selectAllAriaLabel": "Выбрать все сессии",
|
||||
"sessionList.selection.clearLabel": "Очистить",
|
||||
"sessionList.selection.clearAriaLabel": "Очистить выбор",
|
||||
"sessionList.selection.checkboxAriaLabel": "Выбрать сессию",
|
||||
"sessionList.bulkDelete.button": "Удалить {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "Удалить {count} выбранных сессий",
|
||||
"sessionList.bulkDelete.title": "Удалить сессии",
|
||||
"sessionList.bulkDelete.confirmMessage": "Удалить {count} выбранных сессий? Это действие нельзя отменить.",
|
||||
"sessionList.bulkDelete.confirmLabel": "Удалить",
|
||||
"sessionList.bulkDelete.cancelLabel": "Отмена",
|
||||
"sessionList.bulkDelete.error": "Не удалось удалить {count} сессий",
|
||||
|
||||
"sessionRenameDialog.title": "Переименовать сессию",
|
||||
"sessionRenameDialog.description.withLabel": "Обновите название для \"{label}\".",
|
||||
"sessionRenameDialog.description.default": "Установите новое название для этой сессии.",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "Ожидание запуска…",
|
||||
"toolCall.error.label": "Ошибка:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",
|
||||
|
||||
@@ -82,9 +82,14 @@ export const commandMessages = {
|
||||
"commands.clearInput.description": "清空 prompt 输入框",
|
||||
"commands.clearInput.keywords": "clear, reset, 清空, 重置",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "显示思考块",
|
||||
"commands.thinkingBlocks.label.hide": "隐藏思考块",
|
||||
"commands.thinkingBlocks.description": "显示/隐藏 AI 思考过程",
|
||||
"commands.promptSubmitShortcut.label.default": "Enter: 换行, Cmd/Ctrl+Enter: 提交提示词",
|
||||
"commands.promptSubmitShortcut.label.swapped": "Enter: 提交提示词, Cmd/Ctrl+Enter: 换行",
|
||||
"commands.promptSubmitShortcut.description": "在提示词输入框中交换 Enter 与 Cmd/Ctrl+Enter 的行为",
|
||||
"commands.promptSubmitShortcut.keywords": "enter, 换行, 提交, 发送, 快捷键, cmd, ctrl, 提示词",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "显示思考",
|
||||
"commands.thinkingBlocks.label.hide": "隐藏思考",
|
||||
"commands.thinkingBlocks.description": "显示或隐藏 AI 思考部分",
|
||||
"commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide, 思考, 推理, 切换, 显示, 隐藏",
|
||||
|
||||
"commands.timelineToolCalls.label.show": "显示时间轴工具调用",
|
||||
@@ -99,8 +104,8 @@ export const commandMessages = {
|
||||
"commands.common.enabled": "启用",
|
||||
"commands.common.disabled": "禁用",
|
||||
|
||||
"commands.thinkingBlocksDefault.label": "思考块默认 · {state}",
|
||||
"commands.thinkingBlocksDefault.description": "切换思考块是否默认展开",
|
||||
"commands.thinkingBlocksDefault.label": "思考视图: {state}",
|
||||
"commands.thinkingBlocksDefault.description": "在显示时折叠 / 展开 AI 思考部分",
|
||||
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default, 思考, 推理, 展开, 折叠, 默认",
|
||||
|
||||
"commands.diffViewSplit.label": "使用分栏 Diff 视图",
|
||||
|
||||
@@ -20,6 +20,9 @@ export const messagingMessages = {
|
||||
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
|
||||
"messageSection.quote.addAsQuote": "作为引用添加",
|
||||
"messageSection.quote.addAsCode": "作为代码添加",
|
||||
"messageSection.quote.copy": "复制",
|
||||
"messageSection.quote.copied": "已复制!",
|
||||
"messageSection.quote.copyFailed": "无法复制",
|
||||
|
||||
"messageTimeline.ariaLabel": "消息时间线",
|
||||
"messageTimeline.segment.user.label": "你",
|
||||
@@ -38,6 +41,11 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "前往会话",
|
||||
"messageBlock.tool.goToSession.title": "前往会话",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "会话尚不可用",
|
||||
"messageBlock.tool.deletePart.label": "删除",
|
||||
"messageBlock.tool.deletePart.deleting": "正在删除...",
|
||||
"messageBlock.tool.deletePart.title": "删除此工具输出",
|
||||
"messageBlock.tool.deletePart.failed.title": "删除失败",
|
||||
"messageBlock.tool.deletePart.failed.message": "删除工具输出失败",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "会话压缩",
|
||||
"messageBlock.compaction.autoLabel": "会话已自动压缩",
|
||||
@@ -73,6 +81,11 @@ export const messagingMessages = {
|
||||
"messageItem.status.generating": "正在生成...",
|
||||
"messageItem.status.sending": "正在发送...",
|
||||
"messageItem.status.failedToSend": "消息发送失败",
|
||||
"messagePart.actions.delete": "删除",
|
||||
"messagePart.actions.deleting": "正在删除...",
|
||||
"messagePart.actions.deleteTitle": "删除此项",
|
||||
"messagePart.actions.deleteFailedTitle": "删除失败",
|
||||
"messagePart.actions.deleteFailedMessage": "删除失败",
|
||||
"messageItem.attachment.defaultName": "附件",
|
||||
"messageItem.attachment.downloadAriaLabel": "下载 {name}",
|
||||
"messageItem.agentMeta.agentLabel": "智能体:{agent}",
|
||||
|
||||
@@ -30,8 +30,27 @@ export const sessionMessages = {
|
||||
"sessionList.copyId.success": "已复制会话 ID",
|
||||
"sessionList.copyId.error": "无法复制会话 ID",
|
||||
"sessionList.delete.error": "无法删除会话",
|
||||
"sessionList.delete.title": "删除会话",
|
||||
"sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。",
|
||||
"sessionList.delete.confirmLabel": "删除",
|
||||
"sessionList.delete.cancelLabel": "取消",
|
||||
"sessionList.rename.error": "无法重命名会话",
|
||||
|
||||
"sessionList.filter.placeholder": "搜索会话…",
|
||||
"sessionList.filter.ariaLabel": "搜索会话",
|
||||
"sessionList.selection.selectAllLabel": "全选",
|
||||
"sessionList.selection.selectAllAriaLabel": "选择所有会话",
|
||||
"sessionList.selection.clearLabel": "清除",
|
||||
"sessionList.selection.clearAriaLabel": "清除选择",
|
||||
"sessionList.selection.checkboxAriaLabel": "选择会话",
|
||||
"sessionList.bulkDelete.button": "删除 {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "删除已选择的 {count} 个会话",
|
||||
"sessionList.bulkDelete.title": "删除会话",
|
||||
"sessionList.bulkDelete.confirmMessage": "删除已选择的 {count} 个会话?此操作无法撤销。",
|
||||
"sessionList.bulkDelete.confirmLabel": "删除",
|
||||
"sessionList.bulkDelete.cancelLabel": "取消",
|
||||
"sessionList.bulkDelete.error": "无法删除 {count} 个会话",
|
||||
|
||||
"sessionRenameDialog.title": "重命名会话",
|
||||
"sessionRenameDialog.description.withLabel": "更新“{label}”的标题。",
|
||||
"sessionRenameDialog.description.default": "为此会话设置新标题。",
|
||||
|
||||
@@ -2,6 +2,9 @@ export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "等待运行...",
|
||||
"toolCall.error.label": "错误:",
|
||||
|
||||
"toolCall.header.copyTitle": "Copy tool call title",
|
||||
"toolCall.header.copyAriaLabel": "Copy tool call title",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",
|
||||
|
||||
@@ -91,7 +91,7 @@ async function getOrCreateHighlighter() {
|
||||
|
||||
// Create highlighter with no preloaded languages
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-light", "github-dark"],
|
||||
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
||||
langs: [],
|
||||
})
|
||||
|
||||
@@ -242,9 +242,9 @@ async function runLanguageLoadQueue() {
|
||||
}
|
||||
|
||||
function setupRenderer(isDark: boolean) {
|
||||
if (!highlighter || rendererSetup) return
|
||||
|
||||
currentTheme = isDark ? "dark" : "light"
|
||||
if (!highlighter) return
|
||||
if (rendererSetup) return
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
@@ -296,10 +296,10 @@ function setupRenderer(isDark: boolean) {
|
||||
// Use highlighting if language is loaded, otherwise fall back to plain code
|
||||
if (loadedLanguages.has(langKey)) {
|
||||
try {
|
||||
const html = highlighter!.codeToHtml(decodedCode, {
|
||||
lang: langKey,
|
||||
theme: currentTheme === "dark" ? "github-dark" : "github-light",
|
||||
})
|
||||
const html = highlighter!.codeToHtml(decodedCode, {
|
||||
lang: langKey,
|
||||
theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast",
|
||||
})
|
||||
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
|
||||
} catch {
|
||||
// Fall through to plain code if highlighting fails
|
||||
@@ -329,6 +329,10 @@ export async function initMarkdown(isDark: boolean) {
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
export function setMarkdownTheme(isDark: boolean) {
|
||||
currentTheme = isDark ? "dark" : "light"
|
||||
}
|
||||
|
||||
export function isMarkdownReady(): boolean {
|
||||
return isInitialized && highlighter !== null
|
||||
}
|
||||
|
||||
158
packages/ui/src/lib/native/wake-lock.ts
Normal file
158
packages/ui/src/lib/native/wake-lock.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
let desired = false
|
||||
let inFlight: Promise<boolean> | null = null
|
||||
|
||||
let applied = false
|
||||
|
||||
let webWakeLock: any = null
|
||||
|
||||
async function setWebWakeLock(enabled: boolean): Promise<boolean> {
|
||||
if (typeof navigator === "undefined") return false
|
||||
|
||||
const api = (navigator as any).wakeLock
|
||||
if (!api?.request) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
if (webWakeLock) {
|
||||
return true
|
||||
}
|
||||
webWakeLock = await api.request("screen")
|
||||
try {
|
||||
webWakeLock.addEventListener?.("release", () => {
|
||||
// If the lock is released by the UA (e.g., tab hidden), clear local state.
|
||||
webWakeLock = null
|
||||
if (desired) {
|
||||
// Re-acquire best-effort.
|
||||
queueMicrotask(() => {
|
||||
void setWakeLockDesired(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// optional
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (webWakeLock) {
|
||||
await webWakeLock.release?.()
|
||||
}
|
||||
webWakeLock = null
|
||||
return false
|
||||
} catch (error) {
|
||||
log.log("[wake-lock] web wake lock failed", error)
|
||||
webWakeLock = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnyWakeLockSupport(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
if (runtimeEnv.host === "electron") {
|
||||
const api = (window as any).electronAPI
|
||||
if (api?.setWakeLock) return true
|
||||
}
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
// We'll attempt dynamic import; treat as potentially supported.
|
||||
return true
|
||||
}
|
||||
return Boolean((navigator as any)?.wakeLock?.request)
|
||||
}
|
||||
|
||||
async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
|
||||
const api = (window as typeof window & { electronAPI?: { setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }> } })
|
||||
.electronAPI
|
||||
if (!api?.setWakeLock) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.setWakeLock(Boolean(enabled))
|
||||
return Boolean(result?.enabled)
|
||||
} catch (error) {
|
||||
log.log("[wake-lock] electron wake lock failed", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
||||
try {
|
||||
const mod = await import("tauri-plugin-keepawake-api")
|
||||
const start = (mod as any).start as ((config?: any) => Promise<void>) | undefined
|
||||
const stop = (mod as any).stop as (() => Promise<void>) | undefined
|
||||
if (!start || !stop) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// Plugin config supports toggling display/idle/sleep. Use a conservative
|
||||
// default to keep both system + display awake.
|
||||
await start({ display: true, idle: true, sleep: true })
|
||||
return true
|
||||
}
|
||||
|
||||
await stop()
|
||||
return false
|
||||
} catch (error) {
|
||||
log.log("[wake-lock] tauri wake lock failed", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyWakeLock(enabled: boolean): Promise<boolean> {
|
||||
if (typeof window === "undefined") return false
|
||||
|
||||
if (runtimeEnv.host === "electron") {
|
||||
const ok = await setElectronWakeLock(enabled)
|
||||
if (ok || !enabled) return ok
|
||||
// fallback to web API if electron preload didn't expose it
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
const ok = await setTauriWakeLock(enabled)
|
||||
if (ok || !enabled) return ok
|
||||
// fallback to web API if tauri command isn't available
|
||||
}
|
||||
|
||||
return await setWebWakeLock(enabled)
|
||||
}
|
||||
|
||||
export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
|
||||
desired = Boolean(nextDesired)
|
||||
|
||||
if (inFlight) {
|
||||
// Coalesce: once the current request resolves, it will re-apply the latest desired state.
|
||||
return inFlight
|
||||
}
|
||||
|
||||
const target = desired
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const ok = await applyWakeLock(target)
|
||||
// Treat disable attempts as applied even if the underlying API doesn't exist.
|
||||
applied = target
|
||||
return ok
|
||||
} finally {
|
||||
inFlight = null
|
||||
// If desired changed while in-flight, re-apply once.
|
||||
if (desired !== applied) {
|
||||
void setWakeLockDesired(desired)
|
||||
}
|
||||
|
||||
// If we tried to enable but there is no support, avoid re-trying forever.
|
||||
if (desired && !hasAnyWakeLockSupport()) {
|
||||
applied = false
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return inFlight!
|
||||
}
|
||||
@@ -4,8 +4,13 @@ import { CODENOMAD_API_BASE } from "./api-client"
|
||||
class SDKManager {
|
||||
private clients = new Map<string, OpencodeClient>()
|
||||
|
||||
createClient(instanceId: string, proxyPath: string): OpencodeClient {
|
||||
const existing = this.clients.get(instanceId)
|
||||
private key(instanceId: string, worktreeSlug: string): string {
|
||||
return `${instanceId}:${worktreeSlug || "root"}`
|
||||
}
|
||||
|
||||
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient {
|
||||
const key = this.key(instanceId, worktreeSlug)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
@@ -13,17 +18,25 @@ class SDKManager {
|
||||
const baseUrl = buildInstanceBaseUrl(proxyPath)
|
||||
const client = createOpencodeClient({ baseUrl })
|
||||
|
||||
this.clients.set(instanceId, client)
|
||||
this.clients.set(key, client)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
getClient(instanceId: string): OpencodeClient | null {
|
||||
return this.clients.get(instanceId) ?? null
|
||||
getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null {
|
||||
return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null
|
||||
}
|
||||
|
||||
destroyClient(instanceId: string): void {
|
||||
this.clients.delete(instanceId)
|
||||
destroyClient(instanceId: string, worktreeSlug = "root"): void {
|
||||
this.clients.delete(this.key(instanceId, worktreeSlug))
|
||||
}
|
||||
|
||||
destroyClientsForInstance(instanceId: string): void {
|
||||
for (const key of Array.from(this.clients.keys())) {
|
||||
if (key === instanceId || key.startsWith(`${instanceId}:`)) {
|
||||
this.clients.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
|
||||
@@ -3,22 +3,24 @@ import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/s
|
||||
import CssBaseline from "@suid/material/CssBaseline"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
export type ThemeMode = "system" | "light" | "dark"
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDark: () => boolean
|
||||
toggleTheme: () => void
|
||||
setTheme: (dark: boolean) => void
|
||||
themeMode: () => ThemeMode
|
||||
setThemeMode: (mode: ThemeMode) => void
|
||||
cycleThemeMode: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>()
|
||||
|
||||
function applyTheme(dark: boolean) {
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
if (typeof document === "undefined") return
|
||||
if (dark) {
|
||||
document.documentElement.setAttribute("data-theme", "dark")
|
||||
if (mode === "system") {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
document.documentElement.setAttribute("data-theme", mode)
|
||||
}
|
||||
|
||||
interface ResolvedPaletteColors {
|
||||
@@ -78,16 +80,31 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
|
||||
const { themePreference, setThemePreference } = useConfig()
|
||||
const [isDark, setIsDarkSignal] = createSignal(true)
|
||||
const [themeRevision, setThemeRevision] = createSignal(0)
|
||||
|
||||
const themeMode = () => themePreference() as ThemeMode
|
||||
|
||||
const resolveDarkTheme = () => {
|
||||
themePreference()
|
||||
return true
|
||||
const mode = themeMode()
|
||||
if (mode === "dark") return true
|
||||
if (mode === "light") return false
|
||||
return mediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
const applyResolvedTheme = () => {
|
||||
const mode = themeMode()
|
||||
const dark = resolveDarkTheme()
|
||||
if (mode === "system") {
|
||||
applyThemeMode("system")
|
||||
} else {
|
||||
applyThemeMode(mode)
|
||||
}
|
||||
setIsDarkSignal(dark)
|
||||
applyTheme(dark)
|
||||
if (typeof window !== "undefined") {
|
||||
requestAnimationFrame(() => setThemeRevision((v) => v + 1))
|
||||
} else {
|
||||
setThemeRevision((v) => v + 1)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -107,15 +124,18 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
}
|
||||
})
|
||||
|
||||
const setTheme = (_dark: boolean) => {
|
||||
setThemePreference("dark")
|
||||
const setThemeMode = (mode: ThemeMode) => {
|
||||
setThemePreference(mode)
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(true)
|
||||
const cycleThemeMode = () => {
|
||||
const current = themeMode()
|
||||
const next: ThemeMode = current === "system" ? "light" : current === "light" ? "dark" : "system"
|
||||
setThemeMode(next)
|
||||
}
|
||||
|
||||
const muiTheme = createMemo(() => {
|
||||
themeRevision()
|
||||
const paletteColors = resolvePaletteColors(isDark())
|
||||
return createTheme({
|
||||
palette: {
|
||||
@@ -144,21 +164,32 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: "inherit",
|
||||
"&.Mui-disabled": {
|
||||
color: "var(--text-muted)",
|
||||
opacity: 0.55,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: paletteColors.backgroundPaper,
|
||||
color: paletteColors.textPrimary,
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: paletteColors.backgroundPaper,
|
||||
color: paletteColors.textPrimary,
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
boxShadow: "none",
|
||||
borderBottom: `1px solid ${paletteColors.divider}`,
|
||||
borderBottom: "1px solid var(--border-base)",
|
||||
zIndex: 10,
|
||||
},
|
||||
},
|
||||
@@ -175,7 +206,7 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
})
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>
|
||||
<ThemeContext.Provider value={{ isDark, themeMode, setThemeMode, cycleThemeMode }}>
|
||||
<MuiThemeProvider theme={muiTheme()}>
|
||||
<CssBaseline />
|
||||
{props.children}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfigProvider } from "./stores/preferences"
|
||||
import { InstanceConfigProvider } from "./stores/instance-config"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { I18nProvider } from "./lib/i18n"
|
||||
import { storage } from "./lib/storage"
|
||||
import "./index.css"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
|
||||
@@ -14,22 +15,48 @@ if (!root) {
|
||||
throw new Error("Root element not found")
|
||||
}
|
||||
|
||||
const mount = root
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.runtimeHost = runtimeEnv.host
|
||||
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<ConfigProvider>
|
||||
<InstanceConfigProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</InstanceConfigProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
async function bootstrap() {
|
||||
if (typeof document !== "undefined") {
|
||||
// renderer/index.html currently seeds a dark theme to avoid a white flash.
|
||||
// Reset to CSS defaults immediately so the first render matches system
|
||||
// (and then refine once persisted config loads).
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
const theme = config?.theme ?? "system"
|
||||
|
||||
if (theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", theme)
|
||||
}
|
||||
} catch {
|
||||
// If config fails to load, fall back to CSS defaults.
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<ConfigProvider>
|
||||
<InstanceConfigProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</InstanceConfigProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
mount,
|
||||
)
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: var(--surface-base, #0f141f);
|
||||
color: var(--text-primary, #cfd4dc);
|
||||
background-color: var(--surface-base, #ffffff);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
font-family: var(--font-family-sans, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -34,7 +34,7 @@ button {
|
||||
.loading-logo {
|
||||
width: 180px;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
|
||||
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
.loading-heading {
|
||||
@@ -47,13 +47,13 @@ button {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #f4f6fb);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.loading-status {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted, #aeb3c4);
|
||||
color: var(--text-muted, #666666);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
@@ -62,9 +62,9 @@ button {
|
||||
max-width: 420px;
|
||||
padding: 22px;
|
||||
border-radius: 18px;
|
||||
background: rgba(13, 16, 24, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
|
||||
background: var(--surface-secondary, #f5f5f5);
|
||||
border: 1px solid var(--border-base, #e0e0e0);
|
||||
box-shadow: var(--panel-shadow-strong, 0 25px 60px rgba(0, 0, 0, 0.16));
|
||||
}
|
||||
|
||||
.loading-row {
|
||||
@@ -79,28 +79,74 @@ button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
border-top-color: #6ce3ff;
|
||||
border: 2px solid color-mix(in srgb, var(--text-primary, #1a1a1a) 14%, transparent);
|
||||
border-top-color: var(--accent-primary, #0066ff);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.phrase-controls {
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #8f96a9);
|
||||
color: var(--text-muted, #666666);
|
||||
}
|
||||
|
||||
.phrase-controls button {
|
||||
color: #8fb5ff;
|
||||
color: var(--accent-primary, #0066ff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading-error {
|
||||
margin-top: 12px;
|
||||
color: #ff9ea9;
|
||||
color: var(--status-error, #dc2626);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) body {
|
||||
background-color: var(--surface-base, #0f141f);
|
||||
color: var(--text-primary, #cfd4dc);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-logo {
|
||||
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-title {
|
||||
color: var(--text-primary, #f4f6fb);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-status {
|
||||
color: var(--text-muted, #aeb3c4);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-card {
|
||||
background: rgba(13, 16, 24, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .spinner {
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
border-top-color: var(--accent-primary, #6ce3ff);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .phrase-controls {
|
||||
color: var(--text-muted, #8f96a9);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .loading-error {
|
||||
color: var(--status-error, #ff9ea9);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
fetchProviders,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded } from "./worktrees"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences } from "./preferences"
|
||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||
@@ -136,10 +137,10 @@ function attachClient(descriptor: WorkspaceDescriptor) {
|
||||
}
|
||||
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(descriptor.id)
|
||||
sdkManager.destroyClientsForInstance(descriptor.id)
|
||||
}
|
||||
|
||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
|
||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath, "root")
|
||||
updateInstance(descriptor.id, {
|
||||
client,
|
||||
port: nextPort ?? 0,
|
||||
@@ -157,7 +158,7 @@ function releaseInstanceResources(instanceId: string) {
|
||||
if (!instance) return
|
||||
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(instanceId)
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
sseManager.seedStatus(instanceId, "disconnected")
|
||||
}
|
||||
@@ -227,6 +228,8 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
try {
|
||||
await ensureWorktreesLoaded(instanceId)
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
await fetchProviders(instanceId)
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
thinkingBlocksExpansion: ExpansionPreference
|
||||
showTimelineTools: boolean
|
||||
promptSubmitOnEnter: boolean
|
||||
lastUsedBinary?: string
|
||||
locale?: string
|
||||
environmentVariables: Record<string, string>
|
||||
@@ -73,6 +74,7 @@ const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
thinkingBlocksExpansion: "expanded",
|
||||
showTimelineTools: true,
|
||||
promptSubmitOnEnter: false,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
modelFavorites: [],
|
||||
@@ -120,6 +122,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
|
||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter,
|
||||
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
locale: sanitized.locale ?? defaultPreferences.locale,
|
||||
environmentVariables,
|
||||
@@ -194,7 +197,7 @@ const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
const preferences = createMemo<Preferences>(() => internalConfig().preferences)
|
||||
const recentFolders = createMemo<RecentFolder[]>(() => internalConfig().recentFolders ?? [])
|
||||
const opencodeBinaries = createMemo<OpenCodeBinary[]>(() => internalConfig().opencodeBinaries ?? [])
|
||||
const themePreference = createMemo<ThemePreference>(() => internalConfig().theme ?? "dark")
|
||||
const themePreference = createMemo<ThemePreference>(() => internalConfig().theme ?? "system")
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
function normalizeConfig(config?: ConfigData | null): ConfigData {
|
||||
@@ -202,7 +205,7 @@ function normalizeConfig(config?: ConfigData | null): ConfigData {
|
||||
preferences: normalizePreferences(config?.preferences),
|
||||
recentFolders: (config?.recentFolders ?? []).map((folder) => ({ ...folder })),
|
||||
opencodeBinaries: (config?.opencodeBinaries ?? []).map((binary) => ({ ...binary })),
|
||||
theme: config?.theme ?? "dark",
|
||||
theme: config?.theme ?? "system",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +384,10 @@ function toggleUsageMetrics(): void {
|
||||
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
|
||||
}
|
||||
|
||||
function togglePromptSubmitOnEnter(): void {
|
||||
updatePreferences({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter })
|
||||
}
|
||||
|
||||
function toggleAutoCleanupBlankSessions(): void {
|
||||
const nextValue = !preferences().autoCleanupBlankSessions
|
||||
log.info("toggle auto cleanup", { value: nextValue })
|
||||
@@ -490,6 +497,7 @@ interface ConfigContextValue {
|
||||
toggleShowTimelineTools: typeof toggleShowTimelineTools
|
||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||
togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter
|
||||
|
||||
setDiffViewMode: typeof setDiffViewMode
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
@@ -526,6 +534,7 @@ const configContextValue: ConfigContextValue = {
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
togglePromptSubmitOnEnter,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
@@ -585,6 +594,7 @@ export {
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
recentFolders,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
|
||||
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
|
||||
import { providers, sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { removeMessagePartV2 } from "./message-v2/bridge"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
|
||||
@@ -82,6 +84,9 @@ async function sendMessage(
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
@@ -203,7 +208,7 @@ async function sendMessage(
|
||||
try {
|
||||
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
||||
await requestData(
|
||||
instance.client.session.promptAsync({
|
||||
client.session.promptAsync({
|
||||
sessionID: sessionId,
|
||||
...(requestBody as any),
|
||||
}),
|
||||
@@ -226,6 +231,9 @@ async function executeCustomCommand(
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
@@ -255,7 +263,7 @@ async function executeCustomCommand(
|
||||
}
|
||||
|
||||
await requestData(
|
||||
instance.client.session.command({
|
||||
client.session.command({
|
||||
sessionID: sessionId,
|
||||
...(body as any),
|
||||
}),
|
||||
@@ -269,6 +277,9 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
@@ -277,7 +288,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
||||
const agent = session.agent || "build"
|
||||
|
||||
await requestData(
|
||||
instance.client.session.shell({
|
||||
client.session.shell({
|
||||
sessionID: sessionId,
|
||||
agent,
|
||||
command,
|
||||
@@ -292,12 +303,15 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
log.info("abortSession", { instanceId, sessionId })
|
||||
|
||||
try {
|
||||
log.info("session.abort", { instanceId, sessionId })
|
||||
await requestData(
|
||||
instance.client.session.abort({
|
||||
client.session.abort({
|
||||
sessionID: sessionId,
|
||||
}),
|
||||
"session.abort",
|
||||
@@ -369,6 +383,9 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
@@ -380,7 +397,7 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
||||
}
|
||||
|
||||
await requestData(
|
||||
instance.client.session.update({
|
||||
client.session.update({
|
||||
sessionID: sessionId,
|
||||
title: trimmedTitle,
|
||||
}),
|
||||
@@ -395,8 +412,33 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteMessagePart(instanceId: string, sessionId: string, messageId: string, partId: string): Promise<void> {
|
||||
if (!instanceId || !sessionId || !messageId || !partId) return
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
await requestData(
|
||||
client.part.delete({
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
partID: partId,
|
||||
}),
|
||||
"part.delete",
|
||||
)
|
||||
|
||||
// Optimistic removal; SSE will also broadcast a part-removed event.
|
||||
removeMessagePartV2(instanceId, messageId, partId)
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
deleteMessagePart,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
|
||||
@@ -32,6 +32,13 @@ import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForSession } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import {
|
||||
getOrCreateWorktreeClient,
|
||||
getRootClient,
|
||||
getWorktreeSlugForSession,
|
||||
removeParentSessionMapping,
|
||||
setWorktreeSlugForParentSession,
|
||||
} from "./worktrees"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
@@ -62,6 +69,8 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const rootClient = getRootClient(instanceId)
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.fetchingSessions.set(instanceId, true)
|
||||
@@ -70,7 +79,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
|
||||
try {
|
||||
log.info("session.list", { instanceId })
|
||||
const response = await instance.client.session.list()
|
||||
const response = await rootClient.session.list()
|
||||
|
||||
const sessionMap = new Map<string, Session>()
|
||||
|
||||
@@ -80,7 +89,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
|
||||
let statusById: Record<string, any> = {}
|
||||
try {
|
||||
const statusResponse = await instance.client.session.status()
|
||||
const statusResponse = await rootClient.session.status()
|
||||
if (statusResponse.data && typeof statusResponse.data === "object") {
|
||||
statusById = statusResponse.data as Record<string, any>
|
||||
}
|
||||
@@ -171,6 +180,12 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
// New parent sessions inherit the currently active session's worktree.
|
||||
// If no session is active (fresh instance), fall back to root.
|
||||
const activeId = activeSessionId().get(instanceId)
|
||||
const worktreeSlug = activeId && activeId !== "info" ? getWorktreeSlugForSession(instanceId, activeId) : "root"
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
|
||||
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
|
||||
@@ -189,7 +204,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] POST /session.create for instance ${instanceId}`)
|
||||
const response = await instance.client.session.create()
|
||||
const response = await client.session.create()
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to create session: No data returned")
|
||||
@@ -260,6 +275,11 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
await cleanupBlankSessions(instanceId, session.id)
|
||||
}
|
||||
|
||||
// Persist mapping for this *parent* session (best-effort).
|
||||
await setWorktreeSlugForParentSession(instanceId, session.id, worktreeSlug).catch((error) => {
|
||||
log.warn("Failed to persist session worktree mapping", { instanceId, sessionId: session.id, worktreeSlug, error })
|
||||
})
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
log.error("Failed to create session:", error)
|
||||
@@ -283,6 +303,9 @@ async function forkSession(
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const request: { sessionID: string; messageID?: string } = {
|
||||
sessionID: sourceSessionId,
|
||||
messageID: options?.messageId,
|
||||
@@ -290,7 +313,7 @@ async function forkSession(
|
||||
|
||||
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
||||
const info = await requestData<SessionForkResponse>(
|
||||
instance.client.session.fork(request),
|
||||
client.session.fork(request),
|
||||
"session.fork",
|
||||
)
|
||||
const forkedSession = {
|
||||
@@ -362,6 +385,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const deletingSession = sessions().get(instanceId)?.get(sessionId)
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const deleting = next.deletingSession.get(instanceId) || new Set()
|
||||
@@ -372,7 +400,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
await requestData(instance.client.session.delete({ sessionID: sessionId }), "session.delete")
|
||||
await requestData(client.session.delete({ sessionID: sessionId }), "session.delete")
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
@@ -416,6 +444,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up mapping for deleted parent sessions.
|
||||
if (deletingSession?.parentId === null) {
|
||||
await removeParentSessionMapping(instanceId, sessionId).catch(() => undefined)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete session:", error)
|
||||
throw error
|
||||
@@ -437,9 +470,11 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const rootClient = getRootClient(instanceId)
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
const response = await instance.client.app.agents()
|
||||
const response = await rootClient.app.agents()
|
||||
const agentList = (response.data ?? []).map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description || "",
|
||||
@@ -468,9 +503,11 @@ async function fetchProviders(instanceId: string): Promise<void> {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const rootClient = getRootClient(instanceId)
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /config.providers for instance ${instanceId}`)
|
||||
const response = await instance.client.config.providers()
|
||||
const response = await rootClient.config.providers()
|
||||
if (!response.data) return
|
||||
|
||||
const providerList = response.data.providers.map((provider) => ({
|
||||
@@ -524,6 +561,9 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
@@ -541,7 +581,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
try {
|
||||
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||
const apiMessages = await requestData<any[]>(
|
||||
instance.client.session.messages({ sessionID: sessionId }),
|
||||
client.session.messages({ sessionID: sessionId }),
|
||||
"session.messages",
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user